From be8d63e33b199cafa2656d49ad7128cc32214004 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 2 Mar 2021 21:47:37 -0300 Subject: [PATCH 1/2] Increase truncation threshold with -v, disable with -vv Fix #6682 Fix #8403 --- changelog/8403.improvement.rst | 5 + doc/en/how-to/usage.rst | 239 ++++++++++++++++++++++++++++++ src/_pytest/_io/saferepr.py | 35 ++++- src/_pytest/assertion/__init__.py | 2 + src/_pytest/assertion/rewrite.py | 14 +- src/_pytest/assertion/util.py | 5 + testing/io/test_saferepr.py | 8 + testing/test_assertrewrite.py | 53 +++++++ 8 files changed, 352 insertions(+), 9 deletions(-) create mode 100644 changelog/8403.improvement.rst diff --git a/changelog/8403.improvement.rst b/changelog/8403.improvement.rst new file mode 100644 index 00000000000..ec392245f67 --- /dev/null +++ b/changelog/8403.improvement.rst @@ -0,0 +1,5 @@ +By default, pytest will truncate long strings in assert errors so they don't clutter the output too much, +currently at ``240`` characters by default. + +However, in some cases the longer output helps, or is even crucial, to diagnose a failure. Using ``-v`` will +now increase the truncation threshold to ``2400`` characters, and ``-vv`` or higher will disable truncation entirely. diff --git a/doc/en/how-to/usage.rst b/doc/en/how-to/usage.rst index cba2e01989b..5917c856dde 100644 --- a/doc/en/how-to/usage.rst +++ b/doc/en/how-to/usage.rst @@ -161,6 +161,243 @@ will be shown (because KeyboardInterrupt is caught by pytest). By using this option you make sure a trace is shown. +Verbosity +--------- + +The ``-v`` flag controls the verbosity of pytest output in various aspects: test session progress, assertion +details when tests fail, fixtures details with ``--fixtures``, etc. + +.. regendoc:wipe + +Consider this simple file: + +.. code-block:: python + + # content of test_verbosity_example.py + def test_ok(): + pass + + + def test_words_fail(): + fruits1 = ["banana", "apple", "grapes", "melon", "kiwi"] + fruits2 = ["banana", "apple", "orange", "melon", "kiwi"] + assert fruits1 == fruits2 + + + def test_numbers_fail(): + number_to_text1 = {str(x): x for x in range(5)} + number_to_text2 = {str(x * 10): x * 10 for x in range(5)} + assert number_to_text1 == number_to_text2 + + + def test_long_text_fail(): + long_text = "Lorem ipsum dolor sit amet " * 10 + assert "hello world" in long_text + +Executing pytest normally gives us this output (we are skipping the header to focus on the rest): + +.. code-block:: pytest + + $ pytest --no-header + =========================== test session starts =========================== + collected 4 items + + test_verbosity_example.py .FFF [100%] + + ================================ FAILURES ================================= + _____________________________ test_words_fail _____________________________ + + def test_words_fail(): + fruits1 = ["banana", "apple", "grapes", "melon", "kiwi"] + fruits2 = ["banana", "apple", "orange", "melon", "kiwi"] + > assert fruits1 == fruits2 + E AssertionError: assert ['banana', 'a...elon', 'kiwi'] == ['banana', 'a...elon', 'kiwi'] + E At index 2 diff: 'grapes' != 'orange' + E Use -v to get the full diff + + test_verbosity_example.py:8: AssertionError + ____________________________ test_numbers_fail ____________________________ + + def test_numbers_fail(): + number_to_text1 = {str(x): x for x in range(5)} + number_to_text2 = {str(x * 10): x * 10 for x in range(5)} + > assert number_to_text1 == number_to_text2 + E AssertionError: assert {'0': 0, '1':..., '3': 3, ...} == {'0': 0, '10'...'30': 30, ...} + E Omitting 1 identical items, use -vv to show + E Left contains 4 more items: + E {'1': 1, '2': 2, '3': 3, '4': 4} + E Right contains 4 more items: + E {'10': 10, '20': 20, '30': 30, '40': 40} + E Use -v to get the full diff + + test_verbosity_example.py:14: AssertionError + ___________________________ test_long_text_fail ___________________________ + + def test_long_text_fail(): + long_text = "Lorem ipsum dolor sit amet " * 10 + > assert "hello world" in long_text + E AssertionError: assert 'hello world' in 'Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ips... sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet ' + + test_verbosity_example.py:19: AssertionError + ========================= short test summary info ========================= + FAILED test_verbosity_example.py::test_words_fail - AssertionError: asser... + FAILED test_verbosity_example.py::test_numbers_fail - AssertionError: ass... + FAILED test_verbosity_example.py::test_long_text_fail - AssertionError: a... + ======================= 3 failed, 1 passed in 0.08s ======================= + +Notice that: + +* Each test inside the file is shown by a single character in the output: ``.`` for passing, ``F`` for failure. +* ``test_words_fail`` failed, and we are shown a short summary indicating the index 2 of the two lists differ. +* ``test_numbers_fail`` failed, and we are shown a summary of left/right differences on dictionary items. Identical items are omitted. +* ``test_long_text_fail`` failed, and the right hand side of the ``in`` statement is truncated using ``...``` + because it is longer than an internal threshold (240 characters currently). + +Now we can increase pytest's verbosity: + +.. code-block:: pytest + + $ pytest --no-header -v + =========================== test session starts =========================== + collecting ... collected 4 items + + test_verbosity_example.py::test_ok PASSED [ 25%] + test_verbosity_example.py::test_words_fail FAILED [ 50%] + test_verbosity_example.py::test_numbers_fail FAILED [ 75%] + test_verbosity_example.py::test_long_text_fail FAILED [100%] + + ================================ FAILURES ================================= + _____________________________ test_words_fail _____________________________ + + def test_words_fail(): + fruits1 = ["banana", "apple", "grapes", "melon", "kiwi"] + fruits2 = ["banana", "apple", "orange", "melon", "kiwi"] + > assert fruits1 == fruits2 + E AssertionError: assert ['banana', 'a...elon', 'kiwi'] == ['banana', 'a...elon', 'kiwi'] + E At index 2 diff: 'grapes' != 'orange' + E Full diff: + E - ['banana', 'apple', 'orange', 'melon', 'kiwi'] + E ? ^ ^^ + E + ['banana', 'apple', 'grapes', 'melon', 'kiwi'] + E ? ^ ^ + + + test_verbosity_example.py:8: AssertionError + ____________________________ test_numbers_fail ____________________________ + + def test_numbers_fail(): + number_to_text1 = {str(x): x for x in range(5)} + number_to_text2 = {str(x * 10): x * 10 for x in range(5)} + > assert number_to_text1 == number_to_text2 + E AssertionError: assert {'0': 0, '1':..., '3': 3, ...} == {'0': 0, '10'...'30': 30, ...} + E Omitting 1 identical items, use -vv to show + E Left contains 4 more items: + E {'1': 1, '2': 2, '3': 3, '4': 4} + E Right contains 4 more items: + E {'10': 10, '20': 20, '30': 30, '40': 40} + E Full diff: + E - {'0': 0, '10': 10, '20': 20, '30': 30, '40': 40}... + E + E ...Full output truncated (3 lines hidden), use '-vv' to show + + test_verbosity_example.py:14: AssertionError + ___________________________ test_long_text_fail ___________________________ + + def test_long_text_fail(): + long_text = "Lorem ipsum dolor sit amet " * 10 + > assert "hello world" in long_text + E AssertionError: assert 'hello world' in 'Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet ' + + test_verbosity_example.py:19: AssertionError + ========================= short test summary info ========================= + FAILED test_verbosity_example.py::test_words_fail - AssertionError: asser... + FAILED test_verbosity_example.py::test_numbers_fail - AssertionError: ass... + FAILED test_verbosity_example.py::test_long_text_fail - AssertionError: a... + ======================= 3 failed, 1 passed in 0.07s ======================= + +Notice now that: + +* Each test inside the file gets its own line in the output. +* ``test_words_fail`` now shows the two failing lists in full, in addition to which index differs. +* ``test_numbers_fail`` now shows a text diff of the two dictionaries, truncated. +* ``test_long_text_fail`` no longer truncates the right hand side of the ``in`` statement, because the internal + threshold for truncation is larger now (2400 characters currently). + +Now if we increase verbosity even more: + +.. code-block:: pytest + + $ pytest --no-header -vv + =========================== test session starts =========================== + collecting ... collected 4 items + + test_verbosity_example.py::test_ok PASSED [ 25%] + test_verbosity_example.py::test_words_fail FAILED [ 50%] + test_verbosity_example.py::test_numbers_fail FAILED [ 75%] + test_verbosity_example.py::test_long_text_fail FAILED [100%] + + ================================ FAILURES ================================= + _____________________________ test_words_fail _____________________________ + + def test_words_fail(): + fruits1 = ["banana", "apple", "grapes", "melon", "kiwi"] + fruits2 = ["banana", "apple", "orange", "melon", "kiwi"] + > assert fruits1 == fruits2 + E AssertionError: assert ['banana', 'apple', 'grapes', 'melon', 'kiwi'] == ['banana', 'apple', 'orange', 'melon', 'kiwi'] + E At index 2 diff: 'grapes' != 'orange' + E Full diff: + E - ['banana', 'apple', 'orange', 'melon', 'kiwi'] + E ? ^ ^^ + E + ['banana', 'apple', 'grapes', 'melon', 'kiwi'] + E ? ^ ^ + + + test_verbosity_example.py:8: AssertionError + ____________________________ test_numbers_fail ____________________________ + + def test_numbers_fail(): + number_to_text1 = {str(x): x for x in range(5)} + number_to_text2 = {str(x * 10): x * 10 for x in range(5)} + > assert number_to_text1 == number_to_text2 + E AssertionError: assert {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4} == {'0': 0, '10': 10, '20': 20, '30': 30, '40': 40} + E Common items: + E {'0': 0} + E Left contains 4 more items: + E {'1': 1, '2': 2, '3': 3, '4': 4} + E Right contains 4 more items: + E {'10': 10, '20': 20, '30': 30, '40': 40} + E Full diff: + E - {'0': 0, '10': 10, '20': 20, '30': 30, '40': 40} + E ? - - - - - - - - + E + {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4} + + test_verbosity_example.py:14: AssertionError + ___________________________ test_long_text_fail ___________________________ + + def test_long_text_fail(): + long_text = "Lorem ipsum dolor sit amet " * 10 + > assert "hello world" in long_text + E AssertionError: assert 'hello world' in 'Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet ' + + test_verbosity_example.py:19: AssertionError + ========================= short test summary info ========================= + FAILED test_verbosity_example.py::test_words_fail - AssertionError: asser... + FAILED test_verbosity_example.py::test_numbers_fail - AssertionError: ass... + FAILED test_verbosity_example.py::test_long_text_fail - AssertionError: a... + ======================= 3 failed, 1 passed in 0.07s ======================= + +Notice now that: + +* Each test inside the file gets its own line in the output. +* ``test_words_fail`` gives the same output as before in this case. +* ``test_numbers_fail`` now shows a full text diff of the two dictionaries. +* ``test_long_text_fail`` also doesn't truncate on the right hand side as before, but now pytest won't truncate any + text at all, regardless of its size. + +Those were examples of how verbosity affects normal test session output, but verbosity also is used in other +situations, for example you are shown even fixtures that start with ``_`` if you use ``pytest --fixtures -v``. + +Using higher verbosity levels (``-vvv``, ``-vvvv``, ...) is supported, but has no effect in pytest itself at the moment, +however some plugins might make use of higher verbosity. + .. _`pytest.detailed_failed_tests_usage`: Producing a detailed summary report @@ -171,6 +408,8 @@ making it easy in large test suites to get a clear picture of all failures, skip It defaults to ``fE`` to list failures and errors. +.. regendoc:wipe + Example: .. code-block:: python diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index 440b8cbbb54..fa123d2cb19 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -36,12 +36,23 @@ def _ellipsize(s: str, maxsize: int) -> str: class SafeRepr(reprlib.Repr): - """repr.Repr that limits the resulting size of repr() and includes - information on exceptions raised during the call.""" + """ + repr.Repr that limits the resulting size of repr() and includes + information on exceptions raised during the call. + """ - def __init__(self, maxsize: int) -> None: + def __init__(self, maxsize: Optional[int]) -> None: + """ + :param maxsize: + If not None, will truncate the resulting repr to that specific size, using ellipsis + somewhere in the middle to hide the extra text. + If None, will not impose any size limits on the returning repr. + """ super().__init__() - self.maxstring = maxsize + # ``maxstring`` is used by the superclass, and needs to be an int; using a + # very large number in case maxsize is None, meaning we want to disable + # truncation. + self.maxstring = maxsize if maxsize is not None else 1_000_000_000 self.maxsize = maxsize def repr(self, x: object) -> str: @@ -51,7 +62,9 @@ def repr(self, x: object) -> str: raise except BaseException as exc: s = _format_repr_exception(exc, x) - return _ellipsize(s, self.maxsize) + if self.maxsize is not None: + s = _ellipsize(s, self.maxsize) + return s def repr_instance(self, x: object, level: int) -> str: try: @@ -60,7 +73,9 @@ def repr_instance(self, x: object, level: int) -> str: raise except BaseException as exc: s = _format_repr_exception(exc, x) - return _ellipsize(s, self.maxsize) + if self.maxsize is not None: + s = _ellipsize(s, self.maxsize) + return s def safeformat(obj: object) -> str: @@ -75,7 +90,11 @@ def safeformat(obj: object) -> str: return _format_repr_exception(exc, obj) -def saferepr(obj: object, maxsize: int = 240) -> str: +# Maximum size of overall repr of objects to display during assertion errors. +DEFAULT_REPR_MAX_SIZE = 240 + + +def saferepr(obj: object, maxsize: Optional[int] = DEFAULT_REPR_MAX_SIZE) -> str: """Return a size-limited safe repr-string for the given object. Failing __repr__ functions of user instances will be represented @@ -83,7 +102,7 @@ def saferepr(obj: object, maxsize: int = 240) -> str: care to never raise exceptions itself. This function is a wrapper around the Repr/reprlib functionality of the - standard 2.6 lib. + stdlib. """ return SafeRepr(maxsize).repr(obj) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a18cf198df0..4c9826f3f26 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -153,6 +153,7 @@ def callbinrepr(op, left: object, right: object) -> Optional[str]: saved_assert_hooks = util._reprcompare, util._assertion_pass util._reprcompare = callbinrepr + util._config = item.config if ihook.pytest_assertion_pass.get_hookimpls(): @@ -164,6 +165,7 @@ def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None: yield util._reprcompare, util._assertion_pass = saved_assert_hooks + util._config = None def pytest_sessionfinish(session: "Session") -> None: diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index a01be76b4d3..6a3222f333d 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -27,6 +27,7 @@ from typing import TYPE_CHECKING from typing import Union +from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE from _pytest._io.saferepr import saferepr from _pytest._version import version from _pytest.assertion import util @@ -427,7 +428,18 @@ def _saferepr(obj: object) -> str: sequences, especially '\n{' and '\n}' are likely to be present in JSON reprs. """ - return saferepr(obj).replace("\n", "\\n") + maxsize = _get_maxsize_for_saferepr(util._config) + return saferepr(obj, maxsize=maxsize).replace("\n", "\\n") + + +def _get_maxsize_for_saferepr(config: Optional[Config]) -> Optional[int]: + """Get `maxsize` configuration for saferepr based on the given config object.""" + verbosity = config.getoption("verbose") if config is not None else 0 + if verbosity >= 2: + return None + if verbosity >= 1: + return DEFAULT_REPR_MAX_SIZE * 10 + return DEFAULT_REPR_MAX_SIZE def _format_assertmsg(obj: object) -> str: diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index da1ffd15e37..0e54335ab36 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -15,6 +15,8 @@ from _pytest._io.saferepr import _pformat_dispatch from _pytest._io.saferepr import safeformat from _pytest._io.saferepr import saferepr +from _pytest.config import Config + # The _reprcompare attribute on the util module is used by the new assertion # interpretation code and assertion rewriter to detect this plugin was @@ -26,6 +28,9 @@ # when pytest_runtest_setup is called. _assertion_pass: Optional[Callable[[int, str, str], None]] = None +# Config object which is assigned during pytest_runtest_protocol. +_config: Optional[Config] = None + def format_explanation(explanation: str) -> str: r"""Format an explanation. diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py index 7a97cf424c5..63d3af822b1 100644 --- a/testing/io/test_saferepr.py +++ b/testing/io/test_saferepr.py @@ -1,5 +1,6 @@ import pytest from _pytest._io.saferepr import _pformat_dispatch +from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE from _pytest._io.saferepr import saferepr @@ -15,6 +16,13 @@ def test_maxsize(): assert s == expected +def test_no_maxsize(): + text = "x" * DEFAULT_REPR_MAX_SIZE * 10 + s = saferepr(text, maxsize=None) + expected = repr(text) + assert s == expected + + def test_maxsize_error_on_instance(): class A: def __repr__(self): diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index f7d9d62ef63..38969d2946e 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -11,6 +11,7 @@ import zipfile from functools import partial from pathlib import Path +from typing import cast from typing import Dict from typing import List from typing import Mapping @@ -19,13 +20,16 @@ import _pytest._code import pytest +from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE from _pytest.assertion import util from _pytest.assertion.rewrite import _get_assertion_exprs +from _pytest.assertion.rewrite import _get_maxsize_for_saferepr from _pytest.assertion.rewrite import AssertionRewritingHook from _pytest.assertion.rewrite import get_cache_dir from _pytest.assertion.rewrite import PYC_TAIL from _pytest.assertion.rewrite import PYTEST_TAG from _pytest.assertion.rewrite import rewrite_asserts +from _pytest.config import Config from _pytest.config import ExitCode from _pytest.pathlib import make_numbered_dir from _pytest.pytester import Pytester @@ -1706,3 +1710,52 @@ def test_foo(): cache_tag=sys.implementation.cache_tag ) assert bar_init_pyc.is_file() + + +class TestReprSizeVerbosity: + """ + Check that verbosity also controls the string length threshold to shorten it using + ellipsis. + """ + + @pytest.mark.parametrize( + "verbose, expected_size", + [ + (0, DEFAULT_REPR_MAX_SIZE), + (1, DEFAULT_REPR_MAX_SIZE * 10), + (2, None), + (3, None), + ], + ) + def test_get_maxsize_for_saferepr(self, verbose: int, expected_size) -> None: + class FakeConfig: + def getoption(self, name: str) -> int: + assert name == "verbose" + return verbose + + config = FakeConfig() + assert _get_maxsize_for_saferepr(cast(Config, config)) == expected_size + + def create_test_file(self, pytester: Pytester, size: int) -> None: + pytester.makepyfile( + f""" + def test_very_long_string(): + text = "x" * {size} + assert "hello world" in text + """ + ) + + def test_default_verbosity(self, pytester: Pytester) -> None: + self.create_test_file(pytester, DEFAULT_REPR_MAX_SIZE) + result = pytester.runpytest() + result.stdout.fnmatch_lines(["*xxx...xxx*"]) + + def test_increased_verbosity(self, pytester: Pytester) -> None: + self.create_test_file(pytester, DEFAULT_REPR_MAX_SIZE) + result = pytester.runpytest("-v") + result.stdout.no_fnmatch_line("*xxx...xxx*") + + def test_max_increased_verbosity(self, pytester: Pytester) -> None: + self.create_test_file(pytester, DEFAULT_REPR_MAX_SIZE * 10) + result = pytester.runpytest("-vv") + result.stdout.no_fnmatch_line("*xxx...xxx*") From c1e057065c612b8471ccceaee40f822a48250eef Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 26 Mar 2021 07:13:52 -0300 Subject: [PATCH 2/2] Fix plugin-list label and script This was changed during the current docs restructing and we missed that it changed the label. --- doc/en/reference/plugin_list.rst | 6 ++++-- scripts/update-plugin-list.py | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/doc/en/reference/plugin_list.rst b/doc/en/reference/plugin_list.rst index 24cacbc71fc..97ab28e2151 100644 --- a/doc/en/reference/plugin_list.rst +++ b/doc/en/reference/plugin_list.rst @@ -1,5 +1,7 @@ -Plugins List -============ +.. _plugin-list: + +Plugin List +=========== PyPI projects that match "pytest-\*" are considered plugins and are listed automatically. Packages classified as inactive are excluded. diff --git a/scripts/update-plugin-list.py b/scripts/update-plugin-list.py index bc4d8a6a66b..4e00f1a4536 100644 --- a/scripts/update-plugin-list.py +++ b/scripts/update-plugin-list.py @@ -6,8 +6,11 @@ import requests import tabulate -FILE_HEAD = r"""Plugins List -============ +FILE_HEAD = r"""\ +.. _plugin-list: + +Plugin List +=========== PyPI projects that match "pytest-\*" are considered plugins and are listed automatically. Packages classified as inactive are excluded.