diff --git a/AUTHORS b/AUTHORS index 7da1f8a0c57..c6f8fbaed9b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -276,6 +276,7 @@ Paweł Adamczak Pedro Algarvio Petter Strandmark Philipp Loose +Pierre Sassoulas Pieter Mulder Piotr Banaszkiewicz Piotr Helm diff --git a/changelog/6267.feature.rst b/changelog/6267.feature.rst new file mode 100644 index 00000000000..ed551cecc33 --- /dev/null +++ b/changelog/6267.feature.rst @@ -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. diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index df9fa90ba7e..539c5e32934 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -38,9 +38,12 @@ 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. + The length of the truncation explanation depends on the number of line + removed. + """ if max_lines is None: max_lines = DEFAULT_MAX_LINES if max_chars is None: @@ -48,35 +51,50 @@ def _truncate_explanation( # Check if truncation required input_char_count = len("".join(input_lines)) - if len(input_lines) <= max_lines and input_char_count <= max_chars: + # The truncation explanation is at least 67 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) + # + 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 + 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) + # We reevaluate the need to truncate following removal of some lines + if len("".join(input_lines)) > tolerable_max_chars: + 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 - truncated_explanation.extend( - [ - "", # Line break - f"...Full output truncated ({truncated_line_count} line" - f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}", - ] - ) + if truncated_explanation[-1]: + truncated_explanation[-1] = truncated_explanation[-1] + "..." + truncated_line_count += 1 # Account for the part-truncated final line + else: + truncated_explanation[-1] = "..." + truncated_explanation += [ + "", + f"...Full output truncated ({truncated_line_count} line" + f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}", + ] return truncated_explanation 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): diff --git a/testing/test_assertion.py b/testing/test_assertion.py index d8844f2e41d..944633d2a77 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -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, ) @@ -1188,10 +1188,11 @@ 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("...") @@ -1205,13 +1206,34 @@ def test_truncates_at_8_lines_when_first_8_lines_are_LT_max_chars(self) -> None: 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: + expl = ["a" * 10 for x in range(2)] + result = truncate._truncate_explanation(expl, max_lines=10, max_chars=10) + assert result == ["aaaaaaaaaa", "aaaaaaaaaa"] + + def test_truncates_edgecase_when_truncation_message_makes_the_result_longer_for_lines( + self, + ) -> None: + expl = ["a" * 10 for x in range(2)] + result = truncate._truncate_explanation(expl, max_lines=1, max_chars=100) + assert result == ["aaaaaaaaaa", "aaaaaaaaaa"] + def test_truncates_at_8_lines_when_first_8_lines_are_EQ_max_chars(self) -> None: 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 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("...")