Skip to content

Commit b629da4

Browse files
committed
Restructure truncation of assertion messages
This addresses ref #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. - 999e7c6 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.
1 parent 4667b4d commit b629da4

3 files changed

Lines changed: 212 additions & 58 deletions

File tree

_pytest/assertion/__init__.py

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from _pytest.assertion import util
99
from _pytest.assertion import rewrite
10+
from _pytest.assertion import truncate
1011

1112

1213
def pytest_addoption(parser):
@@ -98,12 +99,6 @@ def pytest_collection(session):
9899
assertstate.hook.set_session(session)
99100

100101

101-
def _running_on_ci():
102-
"""Check if we're currently running on a CI system."""
103-
env_vars = ['CI', 'BUILD_NUMBER']
104-
return any(var in os.environ for var in env_vars)
105-
106-
107102
def pytest_runtest_setup(item):
108103
"""Setup the pytest_assertrepr_compare hook
109104
@@ -117,8 +112,8 @@ def callbinrepr(op, left, right):
117112
118113
This uses the first result from the hook and then ensures the
119114
following:
120-
* Overly verbose explanations are dropped unless -vv was used or
121-
running on a CI.
115+
* Overly verbose explanations are truncated unless configured otherwise
116+
(eg. if running in verbose mode).
122117
* Embedded newlines are escaped to help util.format_explanation()
123118
later.
124119
* If the rewrite mode is used embedded %-characters are replaced
@@ -131,21 +126,7 @@ def callbinrepr(op, left, right):
131126
config=item.config, op=op, left=left, right=right)
132127
for new_expl in hook_result:
133128
if new_expl:
134-
135-
# Truncate lines if required
136-
if (sum(len(p) for p in new_expl[1:]) > 80*8 and
137-
item.config.option.verbose < 2 and
138-
not _running_on_ci()):
139-
show_max = 10
140-
truncated_count = len(new_expl) - show_max
141-
new_expl[show_max - 1] += " ..."
142-
new_expl[show_max:] = [
143-
py.builtin._totext(""),
144-
py.builtin._totext('...Full output truncated (%d more lines)'
145-
', use "-vv" to show' % truncated_count
146-
),
147-
]
148-
129+
new_expl = truncate.truncate_if_required(new_expl, item)
149130
new_expl = [line.replace("\n", "\\n") for line in new_expl]
150131
res = py.builtin._totext("\n~").join(new_expl)
151132
if item.config.getvalue("assertmode") == "rewrite":

_pytest/assertion/truncate.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
Utilities for truncating assertion output.
3+
4+
Current default behaviour is to truncate assertion explanations at
5+
~8 terminal lines, unless running in "-vv" mode or running on CI.
6+
"""
7+
8+
import os
9+
10+
import py
11+
12+
13+
DEFAULT_MAX_LINES = 8
14+
DEFAULT_MAX_CHARS = 8 * 80
15+
USAGE_MSG = "use '-vv' to show"
16+
17+
18+
def truncate_if_required(explanation, item, max_length=None):
19+
"""
20+
Truncate this assertion explanation if the given test item is eligible.
21+
"""
22+
if _should_truncate_item(item):
23+
return _truncate_explanation(explanation)
24+
return explanation
25+
26+
27+
def _should_truncate_item(item):
28+
"""
29+
Whether or not this test item is eligible for truncation.
30+
"""
31+
verbose = item.config.option.verbose
32+
return verbose < 2 and not _running_on_ci()
33+
34+
35+
def _running_on_ci():
36+
"""Check if we're currently running on a CI system."""
37+
env_vars = ['CI', 'BUILD_NUMBER']
38+
return any(var in os.environ for var in env_vars)
39+
40+
41+
def _truncate_explanation(input_lines, max_lines=None, max_chars=None):
42+
"""
43+
Truncate given list of strings that makes up the assertion explanation.
44+
45+
Truncates to either 8 lines, or 640 characters - whichever the input reaches
46+
first. The remaining lines will be replaced by a usage message.
47+
"""
48+
49+
if max_lines is None:
50+
max_lines = DEFAULT_MAX_LINES
51+
if max_chars is None:
52+
max_chars = DEFAULT_MAX_CHARS
53+
54+
# Check if truncation required
55+
input_char_count = len("".join(input_lines))
56+
if len(input_lines) <= max_lines and input_char_count <= max_chars:
57+
return input_lines
58+
59+
# Truncate first to max_lines, and then truncate to max_chars if max_chars
60+
# is exceeded.
61+
truncated_explanation = input_lines[:max_lines]
62+
truncated_explanation = _truncate_by_char_count(truncated_explanation, max_chars)
63+
64+
# Add ellipsis to final line
65+
truncated_explanation[-1] = truncated_explanation[-1] + "..."
66+
67+
# Append useful message to explanation
68+
truncated_line_count = len(input_lines) - len(truncated_explanation)
69+
truncated_line_count += 1 # Account for the part-truncated final line
70+
msg = '...Full output truncated'
71+
if truncated_line_count == 1:
72+
msg += ' ({0} line hidden)'.format(truncated_line_count)
73+
else:
74+
msg += ' ({0} lines hidden)'.format(truncated_line_count)
75+
msg += ", {0}" .format(USAGE_MSG)
76+
truncated_explanation.extend([
77+
py.builtin._totext(""),
78+
py.builtin._totext(msg),
79+
])
80+
return truncated_explanation
81+
82+
83+
def _truncate_by_char_count(input_lines, max_chars):
84+
# Check if truncation required
85+
if len("".join(input_lines)) <= max_chars:
86+
return input_lines
87+
88+
# Find point at which input length exceeds total allowed length
89+
iterated_char_count = 0
90+
for iterated_index, input_line in enumerate(input_lines):
91+
if iterated_char_count + len(input_line) > max_chars:
92+
break
93+
iterated_char_count += len(input_line)
94+
95+
# Create truncated explanation with modified final line
96+
truncated_result = input_lines[:iterated_index]
97+
final_line = input_lines[iterated_index]
98+
if final_line:
99+
final_line_truncate_point = max_chars - iterated_char_count
100+
final_line = final_line[:final_line_truncate_point]
101+
truncated_result.append(final_line)
102+
return truncated_result

testing/test_assertion.py

Lines changed: 106 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import py
77
import pytest
88
from _pytest.assertion import util
9+
from _pytest.assertion import truncate
910

1011
PY3 = sys.version_info >= (3, 0)
1112

@@ -572,6 +573,111 @@ def test_fmt_multi_newline_before_where(self):
572573
assert util.format_explanation(expl) == res
573574

574575

576+
class TestTruncateExplanation:
577+
578+
""" Confirm assertion output is truncated as expected """
579+
580+
# The number of lines in the truncation explanation message. Used
581+
# to calculate that results have the expected length.
582+
LINES_IN_TRUNCATION_MSG = 2
583+
584+
def test_doesnt_truncate_when_input_is_empty_list(self):
585+
expl = []
586+
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
587+
assert result == expl
588+
589+
def test_doesnt_truncate_at_when_input_is_5_lines_and_LT_max_chars(self):
590+
expl = ['a' * 100 for x in range(5)]
591+
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8*80)
592+
assert result == expl
593+
594+
def test_truncates_at_8_lines_when_given_list_of_empty_strings(self):
595+
expl = ['' for x in range(50)]
596+
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
597+
assert result != expl
598+
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
599+
assert "Full output truncated" in result[-1]
600+
assert "43 lines hidden" in result[-1]
601+
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
602+
assert last_line_before_trunc_msg.endswith("...")
603+
604+
def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self):
605+
expl = ['a' for x in range(100)]
606+
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8*80)
607+
assert result != expl
608+
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
609+
assert "Full output truncated" in result[-1]
610+
assert "93 lines hidden" in result[-1]
611+
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
612+
assert last_line_before_trunc_msg.endswith("...")
613+
614+
def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self):
615+
expl = ['a' * 80 for x in range(16)]
616+
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8*80)
617+
assert result != expl
618+
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
619+
assert "Full output truncated" in result[-1]
620+
assert "9 lines hidden" in result[-1]
621+
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
622+
assert last_line_before_trunc_msg.endswith("...")
623+
624+
def test_truncates_at_4_lines_when_first_4_lines_are_GT_max_chars(self):
625+
expl = ['a' * 250 for x in range(10)]
626+
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=999)
627+
assert result != expl
628+
assert len(result) == 4 + self.LINES_IN_TRUNCATION_MSG
629+
assert "Full output truncated" in result[-1]
630+
assert "7 lines hidden" in result[-1]
631+
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
632+
assert last_line_before_trunc_msg.endswith("...")
633+
634+
def test_truncates_at_1_line_when_first_line_is_GT_max_chars(self):
635+
expl = ['a' * 250 for x in range(1000)]
636+
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
637+
assert result != expl
638+
assert len(result) == 1 + self.LINES_IN_TRUNCATION_MSG
639+
assert "Full output truncated" in result[-1]
640+
assert "1000 lines hidden" in result[-1]
641+
last_line_before_trunc_msg = result[- self.LINES_IN_TRUNCATION_MSG -1]
642+
assert last_line_before_trunc_msg.endswith("...")
643+
644+
def test_full_output_truncated(self, monkeypatch, testdir):
645+
""" Test against full runpytest() output. """
646+
647+
line_count = 7
648+
line_len = 100
649+
expected_truncated_lines = 2
650+
testdir.makepyfile(r"""
651+
def test_many_lines():
652+
a = list([str(i)[0] * %d for i in range(%d)])
653+
b = a[::2]
654+
a = '\n'.join(map(str, a))
655+
b = '\n'.join(map(str, b))
656+
assert a == b
657+
""" % (line_len, line_count))
658+
monkeypatch.delenv('CI', raising=False)
659+
660+
result = testdir.runpytest()
661+
# without -vv, truncate the message showing a few diff lines only
662+
result.stdout.fnmatch_lines([
663+
"*- 1*",
664+
"*- 3*",
665+
"*- 5*",
666+
"*truncated (%d lines hidden)*use*-vv*" % expected_truncated_lines,
667+
])
668+
669+
result = testdir.runpytest('-vv')
670+
result.stdout.fnmatch_lines([
671+
"*- %d*" % 5,
672+
])
673+
674+
monkeypatch.setenv('CI', '1')
675+
result = testdir.runpytest()
676+
result.stdout.fnmatch_lines([
677+
"*- %d*" % 5,
678+
])
679+
680+
575681
def test_python25_compile_issue257(testdir):
576682
testdir.makepyfile("""
577683
def test_rewritten():
@@ -631,40 +737,6 @@ def test_hello():
631737
])
632738

633739

634-
def test_assert_compare_truncate_longmessage(monkeypatch, testdir):
635-
testdir.makepyfile(r"""
636-
def test_long():
637-
a = list(range(200))
638-
b = a[::2]
639-
a = '\n'.join(map(str, a))
640-
b = '\n'.join(map(str, b))
641-
assert a == b
642-
""")
643-
monkeypatch.delenv('CI', raising=False)
644-
645-
result = testdir.runpytest()
646-
# without -vv, truncate the message showing a few diff lines only
647-
result.stdout.fnmatch_lines([
648-
"*- 1",
649-
"*- 3",
650-
"*- 5",
651-
"*- 7",
652-
"*truncated (193 more lines)*use*-vv*",
653-
])
654-
655-
656-
result = testdir.runpytest('-vv')
657-
result.stdout.fnmatch_lines([
658-
"*- 197",
659-
])
660-
661-
monkeypatch.setenv('CI', '1')
662-
result = testdir.runpytest()
663-
result.stdout.fnmatch_lines([
664-
"*- 197",
665-
])
666-
667-
668740
def test_assertrepr_loaded_per_dir(testdir):
669741
testdir.makepyfile(test_base=['def test_base(): assert 1 == 2'])
670742
a = testdir.mkdir('a')
@@ -883,4 +955,3 @@ def f():
883955
result = testdir.runpytest()
884956
result.stdout.fnmatch_lines(["*1 error*"])
885957
assert "AttributeError: 'Module' object has no attribute '_obj'" not in result.stdout.str()
886-

0 commit comments

Comments
 (0)