diff --git a/AUTHORS b/AUTHORS index 374e6ad9bcc..1ee868448d4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -328,6 +328,7 @@ Paul Müller Paul Reece Pauli Virtanen Pavel Karateev +Pavel Zhukov Paweł Adamczak Pedro Algarvio Petter Strandmark diff --git a/changelog/12765.feature.rst b/changelog/12765.feature.rst new file mode 100644 index 00000000000..193c75621f7 --- /dev/null +++ b/changelog/12765.feature.rst @@ -0,0 +1,3 @@ +Thresholds to trigger snippet truncation can now be set with :confval:`truncation_limit_lines` and :confval:`truncation_limit_chars`. + +See :ref:`truncation-params` for more information. diff --git a/doc/en/how-to/output.rst b/doc/en/how-to/output.rst index 8b15f95f0fd..d53dd4b8ec7 100644 --- a/doc/en/how-to/output.rst +++ b/doc/en/how-to/output.rst @@ -549,6 +549,28 @@ captured output: By default, parametrized variants of skipped tests are grouped together if they share the same skip reason. You can use ``--no-fold-skipped`` to print each skipped test separately. + +.. _truncation-params: + +Modifying truncation limits +-------------------------------------------------- + +.. versionadded: 8.4 + +Default truncation limits are 8 lines or 640 characters, whichever comes first. +To set custom truncation limits you can use following ``pytest.ini`` file options: + +.. code-block:: ini + + [pytest] + truncation_limit_lines = 10 + truncation_limit_chars = 90 + +That will cause pytest to truncate the assertions to 10 lines or 90 characters, whichever comes first. + +Setting both :confval:`truncation_limit_lines` and :confval:`truncation_limit_chars` to ``0`` will disable the truncation. +However, setting only one of those values will disable one truncation mode, but will leave the other one intact. + Creating resultlog format files -------------------------------------------------- diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index f7dfb3ffa71..73398ac811e 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1873,6 +1873,46 @@ passed multiple times. The expected format is ``name=value``. For example:: Default: ``all`` +.. confval:: truncation_limit_chars + + Controls maximum number of characters to truncate assertion message contents. + + Setting value to ``0`` disables the character limit for truncation. + + .. code-block:: ini + + [pytest] + truncation_limit_chars = 640 + + pytest truncates the assert messages to a certain limit by default to prevent comparison with large data to overload the console output. + + Default: ``640`` + + .. note:: + + If pytest detects it is :ref:`running on CI `, truncation is disabled automatically. + + +.. confval:: truncation_limit_lines + + Controls maximum number of linesto truncate assertion message contents. + + Setting value to ``0`` disables the lines limit for truncation. + + .. code-block:: ini + + [pytest] + truncation_limit_lines = 8 + + pytest truncates the assert messages to a certain limit by default to prevent comparison with large data to overload the console output. + + Default: ``8`` + + .. note:: + + If pytest detects it is :ref:`running on CI `, truncation is disabled automatically. + + .. confval:: usefixtures List of fixtures that will be applied to all test functions; this is semantically the same to apply diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index f2f1d029b4c..cbdf9fa0298 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -45,6 +45,18 @@ def pytest_addoption(parser: Parser) -> None: help="Enables the pytest_assertion_pass hook. " "Make sure to delete any previously generated pyc cache files.", ) + + parser.addini( + "truncation_limit_lines", + default=None, + help="Set threshold of LINES after which truncation will take effect", + ) + parser.addini( + "truncation_limit_chars", + default=None, + help=("Set threshold of CHARS after which truncation will take effect"), + ) + Config._add_verbosity_ini( parser, Config.VERBOSITY_ASSERTIONS, diff --git a/src/_pytest/assertion/truncate.py b/src/_pytest/assertion/truncate.py index b67f02ccaf8..4854a62ba6b 100644 --- a/src/_pytest/assertion/truncate.py +++ b/src/_pytest/assertion/truncate.py @@ -12,41 +12,54 @@ DEFAULT_MAX_LINES = 8 -DEFAULT_MAX_CHARS = 8 * 80 +DEFAULT_MAX_CHARS = DEFAULT_MAX_LINES * 80 USAGE_MSG = "use '-vv' to show" -def truncate_if_required( - explanation: list[str], item: Item, max_length: int | None = None -) -> list[str]: +def truncate_if_required(explanation: list[str], item: Item) -> list[str]: """Truncate this assertion explanation if the given test item is eligible.""" - if _should_truncate_item(item): - return _truncate_explanation(explanation) + should_truncate, max_lines, max_chars = _get_truncation_parameters(item) + if should_truncate: + return _truncate_explanation( + explanation, + max_lines=max_lines, + max_chars=max_chars, + ) return explanation -def _should_truncate_item(item: Item) -> bool: - """Whether or not this test item is eligible for truncation.""" +def _get_truncation_parameters(item: Item) -> tuple[bool, int, int]: + """Return the truncation parameters related to the given item, as (should truncate, max lines, max chars).""" + # We do not need to truncate if one of conditions is met: + # 1. Verbosity level is 2 or more; + # 2. Test is being run in CI environment; + # 3. Both truncation_limit_lines and truncation_limit_chars + # .ini parameters are set to 0 explicitly. + max_lines = item.config.getini("truncation_limit_lines") + max_lines = int(max_lines if max_lines is not None else DEFAULT_MAX_LINES) + + max_chars = item.config.getini("truncation_limit_chars") + max_chars = int(max_chars if max_chars is not None else DEFAULT_MAX_CHARS) + verbose = item.config.get_verbosity(Config.VERBOSITY_ASSERTIONS) - return verbose < 2 and not util.running_on_ci() + + should_truncate = verbose < 2 and not util.running_on_ci() + should_truncate = should_truncate and (max_lines > 0 or max_chars > 0) + + return should_truncate, max_lines, max_chars def _truncate_explanation( input_lines: list[str], - max_lines: int | None = None, - max_chars: int | None = None, + max_lines: int, + max_chars: int, ) -> list[str]: """Truncate given list of strings that makes up the assertion explanation. - Truncates to either 8 lines, or 640 characters - whichever the input reaches + Truncates to either max_lines, or max_chars - whichever the input reaches 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)) # The length of the truncation explanation depends on the number of lines @@ -71,16 +84,23 @@ def _truncate_explanation( ): return input_lines # Truncate first to max_lines, and then truncate to max_chars if necessary - truncated_explanation = input_lines[:max_lines] + if max_lines > 0: + truncated_explanation = input_lines[:max_lines] + else: + truncated_explanation = input_lines truncated_char = True # We reevaluate the need to truncate chars following removal of some lines - if len("".join(truncated_explanation)) > tolerable_max_chars: + if len("".join(truncated_explanation)) > tolerable_max_chars and max_chars > 0: truncated_explanation = _truncate_by_char_count( truncated_explanation, max_chars ) else: truncated_char = False + if truncated_explanation == input_lines: + # No truncation happened, so we do not need to add any explanations + return truncated_explanation + truncated_line_count = len(input_lines) - len(truncated_explanation) if truncated_explanation[-1]: # Add ellipsis and take into account part-truncated final line diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 31192df0f6f..b10ca1c91f4 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1435,6 +1435,66 @@ def test_many_lines(): result = pytester.runpytest() result.stdout.fnmatch_lines(["* 6*"]) + @pytest.mark.parametrize( + ["truncation_lines", "truncation_chars", "expected_lines_hidden"], + ( + (3, None, 3), + (4, None, 0), + (0, None, 0), + (None, 8, 6), + (None, 9, 0), + (None, 0, 0), + (0, 0, 0), + (0, 1000, 0), + (1000, 0, 0), + ), + ) + def test_truncation_with_ini( + self, + monkeypatch, + pytester: Pytester, + truncation_lines: int | None, + truncation_chars: int | None, + expected_lines_hidden: int, + ) -> None: + pytester.makepyfile( + """\ + string_a = "123456789\\n23456789\\n3" + string_b = "123456789\\n23456789\\n4" + + def test(): + assert string_a == string_b + """ + ) + + # This test produces 6 lines of diff output or 79 characters + # So the effect should be when threshold is < 4 lines (considering 2 additional lines for explanation) + # Or < 9 characters (considering 70 additional characters for explanation) + + monkeypatch.delenv("CI", raising=False) + + ini = "[pytest]\n" + if truncation_lines is not None: + ini += f"truncation_limit_lines = {truncation_lines}\n" + if truncation_chars is not None: + ini += f"truncation_limit_chars = {truncation_chars}\n" + pytester.makeini(ini) + + result = pytester.runpytest() + + if expected_lines_hidden != 0: + result.stdout.fnmatch_lines( + [f"*truncated ({expected_lines_hidden} lines hidden)*"] + ) + else: + result.stdout.no_fnmatch_line("*truncated*") + result.stdout.fnmatch_lines( + [ + "*- 4*", + "*+ 3*", + ] + ) + def test_python25_compile_issue257(pytester: Pytester) -> None: pytester.makepyfile(