Merge pull request #1995 from mattduck/feat/restructure-assert-truncation
Restructure truncation of assertion messages
This commit is contained in:
commit
6876ba9ba6
|
@ -2,11 +2,11 @@
|
||||||
support for presenting detailed information in failing assertions.
|
support for presenting detailed information in failing assertions.
|
||||||
"""
|
"""
|
||||||
import py
|
import py
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from _pytest.assertion import util
|
from _pytest.assertion import util
|
||||||
from _pytest.assertion import rewrite
|
from _pytest.assertion import rewrite
|
||||||
|
from _pytest.assertion import truncate
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
def pytest_addoption(parser):
|
||||||
|
@ -98,12 +98,6 @@ def pytest_collection(session):
|
||||||
assertstate.hook.set_session(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):
|
def pytest_runtest_setup(item):
|
||||||
"""Setup the pytest_assertrepr_compare hook
|
"""Setup the pytest_assertrepr_compare hook
|
||||||
|
|
||||||
|
@ -117,8 +111,8 @@ def pytest_runtest_setup(item):
|
||||||
|
|
||||||
This uses the first result from the hook and then ensures the
|
This uses the first result from the hook and then ensures the
|
||||||
following:
|
following:
|
||||||
* Overly verbose explanations are dropped unless -vv was used or
|
* Overly verbose explanations are truncated unless configured otherwise
|
||||||
running on a CI.
|
(eg. if running in verbose mode).
|
||||||
* Embedded newlines are escaped to help util.format_explanation()
|
* Embedded newlines are escaped to help util.format_explanation()
|
||||||
later.
|
later.
|
||||||
* If the rewrite mode is used embedded %-characters are replaced
|
* If the rewrite mode is used embedded %-characters are replaced
|
||||||
|
@ -131,21 +125,7 @@ def pytest_runtest_setup(item):
|
||||||
config=item.config, op=op, left=left, right=right)
|
config=item.config, op=op, left=left, right=right)
|
||||||
for new_expl in hook_result:
|
for new_expl in hook_result:
|
||||||
if new_expl:
|
if new_expl:
|
||||||
|
new_expl = truncate.truncate_if_required(new_expl, item)
|
||||||
# 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 = [line.replace("\n", "\\n") for line in new_expl]
|
new_expl = [line.replace("\n", "\\n") for line in new_expl]
|
||||||
res = py.builtin._totext("\n~").join(new_expl)
|
res = py.builtin._totext("\n~").join(new_expl)
|
||||||
if item.config.getvalue("assertmode") == "rewrite":
|
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 py
|
||||||
import pytest
|
import pytest
|
||||||
from _pytest.assertion import util
|
from _pytest.assertion import util
|
||||||
|
from _pytest.assertion import truncate
|
||||||
|
|
||||||
PY3 = sys.version_info >= (3, 0)
|
PY3 = sys.version_info >= (3, 0)
|
||||||
|
|
||||||
|
@ -572,6 +573,111 @@ class TestFormatExplanation:
|
||||||
assert util.format_explanation(expl) == res
|
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([
|
||||||
|
"* 6*",
|
||||||
|
])
|
||||||
|
|
||||||
|
monkeypatch.setenv('CI', '1')
|
||||||
|
result = testdir.runpytest()
|
||||||
|
result.stdout.fnmatch_lines([
|
||||||
|
"* 6*",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def test_python25_compile_issue257(testdir):
|
def test_python25_compile_issue257(testdir):
|
||||||
testdir.makepyfile("""
|
testdir.makepyfile("""
|
||||||
def test_rewritten():
|
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):
|
def test_assertrepr_loaded_per_dir(testdir):
|
||||||
testdir.makepyfile(test_base=['def test_base(): assert 1 == 2'])
|
testdir.makepyfile(test_base=['def test_base(): assert 1 == 2'])
|
||||||
a = testdir.mkdir('a')
|
a = testdir.mkdir('a')
|
||||||
|
@ -914,4 +986,3 @@ def test_issue_1944(testdir):
|
||||||
result = testdir.runpytest()
|
result = testdir.runpytest()
|
||||||
result.stdout.fnmatch_lines(["*1 error*"])
|
result.stdout.fnmatch_lines(["*1 error*"])
|
||||||
assert "AttributeError: 'Module' object has no attribute '_obj'" not in result.stdout.str()
|
assert "AttributeError: 'Module' object has no attribute '_obj'" not in result.stdout.str()
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue