From b629da424efa894a37c9d49eb94b14803afa8332 Mon Sep 17 00:00:00 2001 From: Matthew Duck Date: Thu, 22 Sep 2016 00:06:45 +0100 Subject: [PATCH] Restructure truncation of assertion messages This addresses ref https://github.com/pytest-dev/pytest/issues/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. - 999e7c65417f1e97fc89bf66e0da4c5cd84442ec 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. --- _pytest/assertion/__init__.py | 27 +------ _pytest/assertion/truncate.py | 102 ++++++++++++++++++++++++ testing/test_assertion.py | 141 +++++++++++++++++++++++++--------- 3 files changed, 212 insertions(+), 58 deletions(-) create mode 100644 _pytest/assertion/truncate.py diff --git a/_pytest/assertion/__init__.py b/_pytest/assertion/__init__.py index e7f0e58ed..0cdb56e07 100644 --- a/_pytest/assertion/__init__.py +++ b/_pytest/assertion/__init__.py @@ -7,6 +7,7 @@ import sys from _pytest.assertion import util from _pytest.assertion import rewrite +from _pytest.assertion import truncate def pytest_addoption(parser): @@ -98,12 +99,6 @@ def pytest_collection(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): """Setup the pytest_assertrepr_compare hook @@ -117,8 +112,8 @@ def pytest_runtest_setup(item): This uses the first result from the hook and then ensures the following: - * Overly verbose explanations are dropped unless -vv was used or - running on a CI. + * Overly verbose explanations are truncated unless configured otherwise + (eg. if running in verbose mode). * Embedded newlines are escaped to help util.format_explanation() later. * If the rewrite mode is used embedded %-characters are replaced @@ -131,21 +126,7 @@ def pytest_runtest_setup(item): config=item.config, op=op, left=left, right=right) for new_expl in hook_result: if new_expl: - - # 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 = truncate.truncate_if_required(new_expl, item) new_expl = [line.replace("\n", "\\n") for line in new_expl] res = py.builtin._totext("\n~").join(new_expl) if item.config.getvalue("assertmode") == "rewrite": diff --git a/_pytest/assertion/truncate.py b/_pytest/assertion/truncate.py new file mode 100644 index 000000000..3c031b11f --- /dev/null +++ b/_pytest/assertion/truncate.py @@ -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 diff --git a/testing/test_assertion.py b/testing/test_assertion.py index c6afab014..24b3e719b 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -6,6 +6,7 @@ import _pytest.assertion as plugin import py import pytest from _pytest.assertion import util +from _pytest.assertion import truncate PY3 = sys.version_info >= (3, 0) @@ -572,6 +573,111 @@ class TestFormatExplanation: 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([ + "*- %d*" % 5, + ]) + + monkeypatch.setenv('CI', '1') + result = testdir.runpytest() + result.stdout.fnmatch_lines([ + "*- %d*" % 5, + ]) + + def test_python25_compile_issue257(testdir): testdir.makepyfile(""" 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): testdir.makepyfile(test_base=['def test_base(): assert 1 == 2']) a = testdir.mkdir('a') @@ -883,4 +955,3 @@ def test_issue_1944(testdir): result = testdir.runpytest() result.stdout.fnmatch_lines(["*1 error*"]) assert "AttributeError: 'Module' object has no attribute '_obj'" not in result.stdout.str() -