Skip to content

Avoid truncation when truncating means longer output #10446

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ Paweł Adamczak
Pedro Algarvio
Petter Strandmark
Philipp Loose
Pierre Sassoulas
Pieter Mulder
Piotr Banaszkiewicz
Piotr Helm
Expand Down
2 changes: 2 additions & 0 deletions changelog/6267.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
The full output of a test is no longer truncated if the truncation message would be longer than
the hidden text. The line number shown has also been fixed.
67 changes: 44 additions & 23 deletions src/_pytest/assertion/truncate.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,45 +38,66 @@ def _truncate_explanation(
"""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.
first, taking the truncation explanation into account. 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:
# The length of the truncation explanation depends on the number of lines
# removed but is at least 68 characters:
# The real value is
# 64 (for the base message:
# '...\n...Full output truncated (1 line hidden), use '-vv' to show")'
# )
# + 1 (for plural)
# + int(math.log10(len(input_lines) - max_lines)) (number of hidden line, at least 1)
# + 3 for the '...' added to the truncated line
# But if there's more than 100 lines it's very likely that we're going to
# truncate, so we don't need the exact value using log10.
tolerable_max_chars = (
max_chars + 70 # 64 + 1 (for plural) + 2 (for '99') + 3 for '...'
)
# The truncation explanation add two lines to the output
tolerable_max_lines = max_lines + 2
if (
len(input_lines) <= tolerable_max_lines
and input_char_count <= tolerable_max_chars
):
return input_lines

# Truncate first to max_lines, and then truncate to max_chars if max_chars
# is exceeded.
# Truncate first to max_lines, and then truncate to max_chars if necessary
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] + "..."
truncated_char = True
# We reevaluate the need to truncate chars following removal of some lines
if len("".join(truncated_explanation)) > tolerable_max_chars:
truncated_explanation = _truncate_by_char_count(
truncated_explanation, max_chars
)
else:
truncated_char = False

# 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 += f" ({truncated_line_count} line hidden)"
if truncated_explanation[-1]:
# Add ellipsis and take into account part-truncated final line
truncated_explanation[-1] = truncated_explanation[-1] + "..."
if truncated_char:
# It's possible that we did not remove any char from this line
truncated_line_count += 1
else:
msg += f" ({truncated_line_count} lines hidden)"
msg += f", {USAGE_MSG}"
truncated_explanation.extend(["", str(msg)])
return truncated_explanation
# Add proper ellipsis when we were able to fit a full line exactly
truncated_explanation[-1] = "..."
return truncated_explanation + [
"",
f"...Full output truncated ({truncated_line_count} line"
f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}",
]


def _truncate_by_char_count(input_lines: List[str], max_chars: int) -> List[str]:
# 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):
Expand Down
49 changes: 37 additions & 12 deletions testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,9 +807,9 @@ def test_dataclasses(self, pytester: Pytester) -> None:
"E ['field_b']",
"E ",
"E Drill down into differing attribute field_b:",
"E field_b: 'b' != 'c'...",
"E ",
"E ...Full output truncated (3 lines hidden), use '-vv' to show",
"E field_b: 'b' != 'c'",
"E - c",
"E + b",
],
consecutive=True,
)
Expand All @@ -827,7 +827,7 @@ def test_recursive_dataclasses(self, pytester: Pytester) -> None:
"E Drill down into differing attribute g:",
"E g: S(a=10, b='ten') != S(a=20, b='xxx')...",
"E ",
"E ...Full output truncated (52 lines hidden), use '-vv' to show",
"E ...Full output truncated (51 lines hidden), use '-vv' to show",
],
consecutive=True,
)
Expand Down Expand Up @@ -1188,30 +1188,55 @@ def test_doesnt_truncate_at_when_input_is_5_lines_and_LT_max_chars(self) -> None
def test_truncates_at_8_lines_when_given_list_of_empty_strings(self) -> None:
expl = ["" for x in range(50)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=100)
assert len(result) != len(expl)
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]
assert "42 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) -> None:
expl = ["a" for x in range(100)]
total_lines = 100
expl = ["a" for x in range(total_lines)]
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]
assert f"{total_lines - 8} 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_there_is_one_line_to_remove(self) -> None:
"""The number of line in the result is 9, the same number as if we truncated."""
expl = ["a" for x in range(9)]
result = truncate._truncate_explanation(expl, max_lines=8, max_chars=8 * 80)
assert result == expl
assert "truncated" not in result[-1]

def test_truncates_edgecase_when_truncation_message_makes_the_result_longer_for_chars(
self,
) -> None:
line = "a" * 10
expl = [line, line]
result = truncate._truncate_explanation(expl, max_lines=10, max_chars=10)
assert result == [line, line]

def test_truncates_edgecase_when_truncation_message_makes_the_result_longer_for_lines(
self,
) -> None:
line = "a" * 10
expl = [line, line]
result = truncate._truncate_explanation(expl, max_lines=1, max_chars=100)
assert result == [line, line]

def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self) -> None:
expl = ["a" * 80 for x in range(16)]
expl = [chr(97 + x) * 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 len(result) == 16 - 8 + self.LINES_IN_TRUNCATION_MSG
assert "Full output truncated" in result[-1]
assert "9 lines hidden" in result[-1]
assert "8 lines hidden" in result[-1]
last_line_before_trunc_msg = result[-self.LINES_IN_TRUNCATION_MSG - 1]
assert last_line_before_trunc_msg.endswith("...")

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

line_count = 7
line_len = 100
expected_truncated_lines = 2
expected_truncated_lines = 1
pytester.makepyfile(
r"""
def test_many_lines():
Expand All @@ -1261,7 +1286,7 @@ def test_many_lines():
"*+ 1*",
"*+ 3*",
"*+ 5*",
"*truncated (%d lines hidden)*use*-vv*" % expected_truncated_lines,
"*truncated (%d line hidden)*use*-vv*" % expected_truncated_lines,
]
)

Expand Down