Skip to content

Commit b31db48

Browse files
Avoid truncation when truncating means longer output (#10446)
Fixes #6267
1 parent f6adebb commit b31db48

File tree

4 files changed

+84
-35
lines changed

4 files changed

+84
-35
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ Paweł Adamczak
280280
Pedro Algarvio
281281
Petter Strandmark
282282
Philipp Loose
283+
Pierre Sassoulas
283284
Pieter Mulder
284285
Piotr Banaszkiewicz
285286
Piotr Helm

changelog/6267.improvement.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
The full output of a test is no longer truncated if the truncation message would be longer than
2+
the hidden text. The line number shown has also been fixed.

src/_pytest/assertion/truncate.py

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,45 +38,66 @@ def _truncate_explanation(
3838
"""Truncate given list of strings that makes up the assertion explanation.
3939
4040
Truncates to either 8 lines, or 640 characters - whichever the input reaches
41-
first. The remaining lines will be replaced by a usage message.
41+
first, taking the truncation explanation into account. The remaining lines
42+
will be replaced by a usage message.
4243
"""
43-
4444
if max_lines is None:
4545
max_lines = DEFAULT_MAX_LINES
4646
if max_chars is None:
4747
max_chars = DEFAULT_MAX_CHARS
4848

4949
# Check if truncation required
5050
input_char_count = len("".join(input_lines))
51-
if len(input_lines) <= max_lines and input_char_count <= max_chars:
51+
# The length of the truncation explanation depends on the number of lines
52+
# removed but is at least 68 characters:
53+
# The real value is
54+
# 64 (for the base message:
55+
# '...\n...Full output truncated (1 line hidden), use '-vv' to show")'
56+
# )
57+
# + 1 (for plural)
58+
# + int(math.log10(len(input_lines) - max_lines)) (number of hidden line, at least 1)
59+
# + 3 for the '...' added to the truncated line
60+
# But if there's more than 100 lines it's very likely that we're going to
61+
# truncate, so we don't need the exact value using log10.
62+
tolerable_max_chars = (
63+
max_chars + 70 # 64 + 1 (for plural) + 2 (for '99') + 3 for '...'
64+
)
65+
# The truncation explanation add two lines to the output
66+
tolerable_max_lines = max_lines + 2
67+
if (
68+
len(input_lines) <= tolerable_max_lines
69+
and input_char_count <= tolerable_max_chars
70+
):
5271
return input_lines
53-
54-
# Truncate first to max_lines, and then truncate to max_chars if max_chars
55-
# is exceeded.
72+
# Truncate first to max_lines, and then truncate to max_chars if necessary
5673
truncated_explanation = input_lines[:max_lines]
57-
truncated_explanation = _truncate_by_char_count(truncated_explanation, max_chars)
58-
59-
# Add ellipsis to final line
60-
truncated_explanation[-1] = truncated_explanation[-1] + "..."
74+
truncated_char = True
75+
# We reevaluate the need to truncate chars following removal of some lines
76+
if len("".join(truncated_explanation)) > tolerable_max_chars:
77+
truncated_explanation = _truncate_by_char_count(
78+
truncated_explanation, max_chars
79+
)
80+
else:
81+
truncated_char = False
6182

62-
# Append useful message to explanation
6383
truncated_line_count = len(input_lines) - len(truncated_explanation)
64-
truncated_line_count += 1 # Account for the part-truncated final line
65-
msg = "...Full output truncated"
66-
if truncated_line_count == 1:
67-
msg += f" ({truncated_line_count} line hidden)"
84+
if truncated_explanation[-1]:
85+
# Add ellipsis and take into account part-truncated final line
86+
truncated_explanation[-1] = truncated_explanation[-1] + "..."
87+
if truncated_char:
88+
# It's possible that we did not remove any char from this line
89+
truncated_line_count += 1
6890
else:
69-
msg += f" ({truncated_line_count} lines hidden)"
70-
msg += f", {USAGE_MSG}"
71-
truncated_explanation.extend(["", str(msg)])
72-
return truncated_explanation
91+
# Add proper ellipsis when we were able to fit a full line exactly
92+
truncated_explanation[-1] = "..."
93+
return truncated_explanation + [
94+
"",
95+
f"...Full output truncated ({truncated_line_count} line"
96+
f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}",
97+
]
7398

7499

75100
def _truncate_by_char_count(input_lines: List[str], max_chars: int) -> List[str]:
76-
# Check if truncation required
77-
if len("".join(input_lines)) <= max_chars:
78-
return input_lines
79-
80101
# Find point at which input length exceeds total allowed length
81102
iterated_char_count = 0
82103
for iterated_index, input_line in enumerate(input_lines):

testing/test_assertion.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -807,9 +807,9 @@ def test_dataclasses(self, pytester: Pytester) -> None:
807807
"E ['field_b']",
808808
"E ",
809809
"E Drill down into differing attribute field_b:",
810-
"E field_b: 'b' != 'c'...",
811-
"E ",
812-
"E ...Full output truncated (3 lines hidden), use '-vv' to show",
810+
"E field_b: 'b' != 'c'",
811+
"E - c",
812+
"E + b",
813813
],
814814
consecutive=True,
815815
)
@@ -827,7 +827,7 @@ def test_recursive_dataclasses(self, pytester: Pytester) -> None:
827827
"E Drill down into differing attribute g:",
828828
"E g: S(a=10, b='ten') != S(a=20, b='xxx')...",
829829
"E ",
830-
"E ...Full output truncated (52 lines hidden), use '-vv' to show",
830+
"E ...Full output truncated (51 lines hidden), use '-vv' to show",
831831
],
832832
consecutive=True,
833833
)
@@ -1188,30 +1188,55 @@ def test_doesnt_truncate_at_when_input_is_5_lines_and_LT_max_chars(self) -> None
11881188
def test_truncates_at_8_lines_when_given_list_of_empty_strings(self) -> None:
11891189
expl = ["" for x in range(50)]
11901190
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
1191+
assert len(result) != len(expl)
11911192
assert result != expl
11921193
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
11931194
assert "Full output truncated" in result[-1]
1194-
assert "43 lines hidden" in result[-1]
1195+
assert "42 lines hidden" in result[-1]
11951196
last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1]
11961197
assert last_line_before_trunc_msg.endswith("...")
11971198

11981199
def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self) -> None:
1199-
expl = ["a" for x in range(100)]
1200+
total_lines = 100
1201+
expl = ["a" for x in range(total_lines)]
12001202
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80)
12011203
assert result != expl
12021204
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
12031205
assert "Full output truncated" in result[-1]
1204-
assert "93 lines hidden" in result[-1]
1206+
assert f"{total_lines - 8} lines hidden" in result[-1]
12051207
last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1]
12061208
assert last_line_before_trunc_msg.endswith("...")
12071209

1210+
def test_truncates_at_8_lines_when_there_is_one_line_to_remove(self) -> None:
1211+
"""The number of line in the result is 9, the same number as if we truncated."""
1212+
expl = ["a" for x in range(9)]
1213+
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80)
1214+
assert result == expl
1215+
assert "truncated" not in result[-1]
1216+
1217+
def test_truncates_edgecase_when_truncation_message_makes_the_result_longer_for_chars(
1218+
self,
1219+
) -> None:
1220+
line = "a" * 10
1221+
expl = [line, line]
1222+
result = truncate._truncate_explanation(expl, max_lines=10, max_chars=10)
1223+
assert result == [line, line]
1224+
1225+
def test_truncates_edgecase_when_truncation_message_makes_the_result_longer_for_lines(
1226+
self,
1227+
) -> None:
1228+
line = "a" * 10
1229+
expl = [line, line]
1230+
result = truncate._truncate_explanation(expl, max_lines=1, max_chars=100)
1231+
assert result == [line, line]
1232+
12081233
def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self) -> None:
1209-
expl = ["a" * 80 for x in range(16)]
1234+
expl = [chr(97 + x) * 80 for x in range(16)]
12101235
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80)
12111236
assert result != expl
1212-
assert len(result) == 8 + self.LINES_IN_TRUNCATION_MSG
1237+
assert len(result) == 16 - 8 + self.LINES_IN_TRUNCATION_MSG
12131238
assert "Full output truncated" in result[-1]
1214-
assert "9 lines hidden" in result[-1]
1239+
assert "8 lines hidden" in result[-1]
12151240
last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1]
12161241
assert last_line_before_trunc_msg.endswith("...")
12171242

@@ -1240,7 +1265,7 @@ def test_full_output_truncated(self, monkeypatch, pytester: Pytester) -> None:
12401265

12411266
line_count = 7
12421267
line_len = 100
1243-
expected_truncated_lines = 2
1268+
expected_truncated_lines = 1
12441269
pytester.makepyfile(
12451270
r"""
12461271
def test_many_lines():
@@ -1261,7 +1286,7 @@ def test_many_lines():
12611286
"*+ 1*",
12621287
"*+ 3*",
12631288
"*+ 5*",
1264-
"*truncated (%d lines hidden)*use*-vv*" % expected_truncated_lines,
1289+
"*truncated (%d line hidden)*use*-vv*" % expected_truncated_lines,
12651290
]
12661291
)
12671292

0 commit comments

Comments
 (0)