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:
parent
4667b4decc
commit
b629da424e
|
@ -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":
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in New Issue