Restructure truncation of assertion messages

This addresses ref https://github.com/pytest-dev/pytest/issues/1954.

The current truncation for assertion explanations does not deal with long lines
properly:

- Previously if lines were too long it would display a "-n more lines"
  message.

- 999e7c6541 introduced a bug where long lines can
  cause index errors if there are < 10 lines.

Extract the truncation logic into its own file and ensure it can deal with
long lines properly.
This commit is contained in:
Matthew Duck 2016-09-22 00:06:45 +01:00
parent 4667b4decc
commit b629da424e
3 changed files with 212 additions and 58 deletions

View File

@ -7,6 +7,7 @@ import sys
from _pytest.assertion import util
from _pytest.assertion import rewrite
from _pytest.assertion import truncate
def pytest_addoption(parser):
@ -98,12 +99,6 @@ def pytest_collection(session):
assertstate.hook.set_session(session)
def _running_on_ci():
"""Check if we're currently running on a CI system."""
env_vars = ['CI', 'BUILD_NUMBER']
return any(var in os.environ for var in env_vars)
def pytest_runtest_setup(item):
"""Setup the pytest_assertrepr_compare hook
@ -117,8 +112,8 @@ def pytest_runtest_setup(item):
This uses the first result from the hook and then ensures the
following:
* Overly verbose explanations are dropped unless -vv was used or
running on a CI.
* Overly verbose explanations are truncated unless configured otherwise
(eg. if running in verbose mode).
* Embedded newlines are escaped to help util.format_explanation()
later.
* If the rewrite mode is used embedded %-characters are replaced
@ -131,21 +126,7 @@ def pytest_runtest_setup(item):
config=item.config, op=op, left=left, right=right)
for new_expl in hook_result:
if new_expl:
# Truncate lines if required
if (sum(len(p) for p in new_expl[1:]) > 80*8 and
item.config.option.verbose < 2 and
not _running_on_ci()):
show_max = 10
truncated_count = len(new_expl) - show_max
new_expl[show_max - 1] += " ..."
new_expl[show_max:] = [
py.builtin._totext(""),
py.builtin._totext('...Full output truncated (%d more lines)'
', use "-vv" to show' % truncated_count
),
]
new_expl = truncate.truncate_if_required(new_expl, item)
new_expl = [line.replace("\n", "\\n") for line in new_expl]
res = py.builtin._totext("\n~").join(new_expl)
if item.config.getvalue("assertmode") == "rewrite":

View File

@ -0,0 +1,102 @@
"""
Utilities for truncating assertion output.
Current default behaviour is to truncate assertion explanations at
~8 terminal lines, unless running in "-vv" mode or running on CI.
"""
import os
import py
DEFAULT_MAX_LINES = 8
DEFAULT_MAX_CHARS = 8 * 80
USAGE_MSG = "use '-vv' to show"
def truncate_if_required(explanation, item, max_length=None):
"""
Truncate this assertion explanation if the given test item is eligible.
"""
if _should_truncate_item(item):
return _truncate_explanation(explanation)
return explanation
def _should_truncate_item(item):
"""
Whether or not this test item is eligible for truncation.
"""
verbose = item.config.option.verbose
return verbose < 2 and not _running_on_ci()
def _running_on_ci():
"""Check if we're currently running on a CI system."""
env_vars = ['CI', 'BUILD_NUMBER']
return any(var in os.environ for var in env_vars)
def _truncate_explanation(input_lines, max_lines=None, max_chars=None):
"""
Truncate given list of strings that makes up the assertion explanation.
Truncates to either 8 lines, or 640 characters - whichever the input reaches
first. The remaining lines will be replaced by a usage message.
"""
if max_lines is None:
max_lines = DEFAULT_MAX_LINES
if max_chars is None:
max_chars = DEFAULT_MAX_CHARS
# Check if truncation required
input_char_count = len("".join(input_lines))
if len(input_lines) <= max_lines and input_char_count <= max_chars:
return input_lines
# Truncate first to max_lines, and then truncate to max_chars if max_chars
# is exceeded.
truncated_explanation = input_lines[:max_lines]
truncated_explanation = _truncate_by_char_count(truncated_explanation, max_chars)
# Add ellipsis to final line
truncated_explanation[-1] = truncated_explanation[-1] + "..."
# Append useful message to explanation
truncated_line_count = len(input_lines) - len(truncated_explanation)
truncated_line_count += 1 # Account for the part-truncated final line
msg = '...Full output truncated'
if truncated_line_count == 1:
msg += ' ({0} line hidden)'.format(truncated_line_count)
else:
msg += ' ({0} lines hidden)'.format(truncated_line_count)
msg += ", {0}" .format(USAGE_MSG)
truncated_explanation.extend([
py.builtin._totext(""),
py.builtin._totext(msg),
])
return truncated_explanation
def _truncate_by_char_count(input_lines, max_chars):
# Check if truncation required
if len("".join(input_lines)) <= max_chars:
return input_lines
# Find point at which input length exceeds total allowed length
iterated_char_count = 0
for iterated_index, input_line in enumerate(input_lines):
if iterated_char_count + len(input_line) > max_chars:
break
iterated_char_count += len(input_line)
# Create truncated explanation with modified final line
truncated_result = input_lines[:iterated_index]
final_line = input_lines[iterated_index]
if final_line:
final_line_truncate_point = max_chars - iterated_char_count
final_line = final_line[:final_line_truncate_point]
truncated_result.append(final_line)
return truncated_result

View File

@ -6,6 +6,7 @@ import _pytest.assertion as plugin
import py
import pytest
from _pytest.assertion import util
from _pytest.assertion import truncate
PY3 = sys.version_info >= (3, 0)
@ -572,6 +573,111 @@ class TestFormatExplanation:
assert util.format_explanation(expl) == res
class TestTruncateExplanation:
""" Confirm assertion output is truncated as expected """
# The number of lines in the truncation explanation message. Used
# to calculate that results have the expected length.
LINES_IN_TRUNCATION_MSG = 2
def test_doesnt_truncate_when_input_is_empty_list(self):
expl = []
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
assert result == expl
def test_doesnt_truncate_at_when_input_is_5_lines_and_LT_max_chars(self):
expl = ['a' * 100 for x in range(5)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8*80)
assert result == expl
def test_truncates_at_8_lines_when_given_list_of_empty_strings(self):
expl = ['' for x in range(50)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
assert result != expl
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
assert "43 lines hidden" in result[-1]
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
assert last_line_before_trunc_msg.endswith("...")
def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self):
expl = ['a' for x in range(100)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8*80)
assert result != expl
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
assert "93 lines hidden" in result[-1]
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
assert last_line_before_trunc_msg.endswith("...")
def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self):
expl = ['a' * 80 for x in range(16)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8*80)
assert result != expl
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
assert "9 lines hidden" in result[-1]
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
assert last_line_before_trunc_msg.endswith("...")
def test_truncates_at_4_lines_when_first_4_lines_are_GT_max_chars(self):
expl = ['a' * 250 for x in range(10)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=999)
assert result != expl
assert len(result) == 4 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
assert "7 lines hidden" in result[-1]
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
assert last_line_before_trunc_msg.endswith("...")
def test_truncates_at_1_line_when_first_line_is_GT_max_chars(self):
expl = ['a' * 250 for x in range(1000)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
assert result != expl
assert len(result) == 1 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
assert "1000 lines hidden" in result[-1]
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
assert last_line_before_trunc_msg.endswith("...")
def test_full_output_truncated(self, monkeypatch, testdir):
""" Test against full runpytest() output. """
line_count = 7
line_len = 100
expected_truncated_lines = 2
testdir.makepyfile(r"""
def test_many_lines():
a = list([str(i)[0] * %d for i in range(%d)])
b = a[::2]
a = '\n'.join(map(str, a))
b = '\n'.join(map(str, b))
assert a == b
""" % (line_len, line_count))
monkeypatch.delenv('CI', raising=False)
result = testdir.runpytest()
# without -vv, truncate the message showing a few diff lines only
result.stdout.fnmatch_lines([
"*- 1*",
"*- 3*",
"*- 5*",
"*truncated (%d lines hidden)*use*-vv*" % expected_truncated_lines,
])
result = testdir.runpytest('-vv')
result.stdout.fnmatch_lines([
"*- %d*" % 5,
])
monkeypatch.setenv('CI', '1')
result = testdir.runpytest()
result.stdout.fnmatch_lines([
"*- %d*" % 5,
])
def test_python25_compile_issue257(testdir):
testdir.makepyfile("""
def test_rewritten():
@ -631,40 +737,6 @@ def test_sequence_comparison_uses_repr(testdir):
])
def test_assert_compare_truncate_longmessage(monkeypatch, testdir):
testdir.makepyfile(r"""
def test_long():
a = list(range(200))
b = a[::2]
a = '\n'.join(map(str, a))
b = '\n'.join(map(str, b))
assert a == b
""")
monkeypatch.delenv('CI', raising=False)
result = testdir.runpytest()
# without -vv, truncate the message showing a few diff lines only
result.stdout.fnmatch_lines([
"*- 1",
"*- 3",
"*- 5",
"*- 7",
"*truncated (193 more lines)*use*-vv*",
])
result = testdir.runpytest('-vv')
result.stdout.fnmatch_lines([
"*- 197",
])
monkeypatch.setenv('CI', '1')
result = testdir.runpytest()
result.stdout.fnmatch_lines([
"*- 197",
])
def test_assertrepr_loaded_per_dir(testdir):
testdir.makepyfile(test_base=['def test_base(): assert 1 == 2'])
a = testdir.mkdir('a')
@ -883,4 +955,3 @@ def test_issue_1944(testdir):
result = testdir.runpytest()
result.stdout.fnmatch_lines(["*1 error*"])
assert "AttributeError: 'Module' object has no attribute '_obj'" not in result.stdout.str()