diff --git a/AUTHORS b/AUTHORS index 2805077f42c..8ba33546ef3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -375,6 +375,7 @@ Omri Golan Ondřej Súkup Oscar Benjamin Parth Patel +Parzival235 Patrick Hayes Patrick Lannigan Paul Müller diff --git a/changelog/14859.improvement.rst b/changelog/14859.improvement.rst new file mode 100644 index 00000000000..34dc8dd8da5 --- /dev/null +++ b/changelog/14859.improvement.rst @@ -0,0 +1,2 @@ +Pytest's uses of ``pprint`` now consistently expand nested data structures on +all supported Python versions. diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index 3f5c956d9cf..eba7c288cb2 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -1,9 +1,10 @@ from __future__ import annotations from itertools import islice -import pprint import reprlib +from _pytest._io.pprint import PrettyPrinter + def _try_repr_or_str(obj: object) -> str: try: @@ -112,7 +113,7 @@ def safeformat(obj: object) -> str: with a short exception info. """ try: - return pprint.pformat(obj) + return PrettyPrinter().pformat(obj) except Exception as exc: return _format_repr_exception(exc, obj) diff --git a/src/_pytest/approx.py b/src/_pytest/approx.py index 34b329df138..d0e21340cd5 100644 --- a/src/_pytest/approx.py +++ b/src/_pytest/approx.py @@ -14,7 +14,6 @@ from decimal import Decimal import math from numbers import Complex -import pprint import sys from typing import Any from typing import Generic @@ -23,6 +22,8 @@ from typing import TypeGuard from typing import TypeVar +from _pytest._io.pprint import PrettyPrinter + if TYPE_CHECKING: from numpy import ndarray @@ -262,7 +263,9 @@ def __init__( for key, value in expected.items(): if isinstance(value, type(expected)): msg = "pytest.approx() does not support nested dictionaries: key={!r} value={!r}\n full mapping={}" - raise TypeError(msg.format(key, value, pprint.pformat(expected))) + raise TypeError( + msg.format(key, value, PrettyPrinter().pformat(expected)) + ) super().__init__(expected, rel=rel, abs=abs, nan_ok=nan_ok) @@ -360,7 +363,7 @@ def __init__( for index, x in enumerate(expected): if isinstance(x, type(expected)): msg = "pytest.approx() does not support nested data structures: {!r} at index {}\n full sequence: {}" - raise TypeError(msg.format(x, index, pprint.pformat(expected))) + raise TypeError(msg.format(x, index, PrettyPrinter().pformat(expected))) super().__init__(expected, rel=rel, abs=abs, nan_ok=nan_ok) diff --git a/src/_pytest/assertion/_compare_any.py b/src/_pytest/assertion/_compare_any.py index 9a1e124bc5b..94a3dd68ac2 100644 --- a/src/_pytest/assertion/_compare_any.py +++ b/src/_pytest/assertion/_compare_any.py @@ -2,8 +2,8 @@ from collections.abc import Iterator import dataclasses -import pprint +from _pytest._io.pprint import PrettyPrinter from _pytest.assertion._compare_mapping import _compare_eq_mapping from _pytest.assertion._compare_sequence import _compare_eq_iterable from _pytest.assertion._compare_sequence import _compare_eq_sequence @@ -105,6 +105,7 @@ def _compare_eq_cls( assert False indent = " " + pp = PrettyPrinter() same = [] diff = [] for field in fields_to_check: @@ -119,10 +120,10 @@ def _compare_eq_cls( yield f"Omitting {len(same)} identical items, use -vv to show" elif same: yield "Matching attributes:" - yield from highlighter(pprint.pformat(same)).splitlines() + yield from (highlighter(line) for line in pp.pformat_lines(same)) if diff: yield "Differing attributes:" - yield from highlighter(pprint.pformat(diff)).splitlines() + yield from (highlighter(line) for line in pp.pformat_lines(diff)) for field in diff: field_left = getattr(left, field) field_right = getattr(right, field) diff --git a/src/_pytest/assertion/_compare_mapping.py b/src/_pytest/assertion/_compare_mapping.py index 6380c3cacd7..12e026c07e8 100644 --- a/src/_pytest/assertion/_compare_mapping.py +++ b/src/_pytest/assertion/_compare_mapping.py @@ -4,9 +4,9 @@ from collections.abc import Iterator from collections.abc import Mapping import heapq -import pprint from _pytest._io.pprint import _safe_key +from _pytest._io.pprint import PrettyPrinter from _pytest._io.saferepr import saferepr from _pytest.assertion._typing import _HighlightFunc from _pytest.assertion._typing import NO_TRUNCATION_BUDGET @@ -23,12 +23,13 @@ def _compare_eq_mapping( set_left = set(left) set_right = set(right) common = set_left.intersection(set_right) - same = {k: left[k] for k in common if left[k] == right[k]} + same = {k: left[k] for k in sorted(common, key=_safe_key) if left[k] == right[k]} + pp = PrettyPrinter() if same and verbose < 2: yield f"Omitting {len(same)} identical items, use -vv to show" elif same: yield "Common items:" - yield from highlighter(pprint.pformat(same)).splitlines() + yield from (highlighter(line) for line in pp.pformat_lines(same)) diff = {k for k in common if left[k] != right[k]} if diff: yield "Differing items:" @@ -61,12 +62,13 @@ def _format_extra_items( """Render the "X contains N more items" subdict.""" max_lines = truncation_budget.max_lines if max_lines == 0 or len(keys) <= max_lines: - # If no need to truncate, let pprint handle it. - yield from highlighter( - pprint.pformat({k: mapping[k] for k in keys}) - ).splitlines() + # If no need to truncate, let the pretty printer handle it. + lines = PrettyPrinter().pformat_lines( + {k: mapping[k] for k in sorted(keys, key=_safe_key)} + ) + yield from (highlighter(line) for line in lines) else: # To avoid spending effort on formatting entries that would be truncated, - # only format the needed entries, keeping the sorting that pprint would use. + # only format the needed entries, keeping deterministic key sorting. for k in heapq.nsmallest(max_lines, keys, key=_safe_key): yield highlighter(saferepr({k: mapping[k]})) diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index db3839198d9..ab331ebfd33 100644 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -21,6 +21,7 @@ from .reports import CollectReport from _pytest import nodes from _pytest._io import TerminalWriter +from _pytest._io.pprint import PrettyPrinter from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl @@ -614,8 +615,6 @@ def cacheshow(config: Config, session: Session) -> int: :param session: pytest session object. :returns: Exit code (0 for success). """ - from pprint import pformat - assert config.cache is not None tw = TerminalWriter() @@ -638,6 +637,7 @@ def globfiles(base: Path) -> Iterable[Path]: yield x dummy = object() + pp = PrettyPrinter() basedir = config.cache._cachedir vdir = basedir / Cache._CACHE_PREFIX_VALUES tw.sep("-", f"cache values for {glob!r}") @@ -648,7 +648,7 @@ def globfiles(base: Path) -> Iterable[Path]: tw.line(f"{key} contains unreadable content, will be ignored") else: tw.line(f"{key} contains:") - for line in pformat(val).splitlines(): + for line in pp.pformat_lines(val): tw.line(" " + line) ddir = basedir / Cache._CACHE_PREFIX_DIRS diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 3e36fc7d286..0fee2925a88 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -6,7 +6,6 @@ from collections.abc import Callable from collections.abc import Generator from collections.abc import Iterator -from pprint import pformat import re from types import TracebackType from typing import Any @@ -24,6 +23,7 @@ import warnings +from _pytest._io.pprint import PrettyPrinter from _pytest.deprecated import check_ispytest from _pytest.fixtures import fixture from _pytest.outcomes import Exit @@ -317,7 +317,7 @@ def __exit__( return def found_str() -> str: - return pformat([record.message for record in self], indent=2) + return PrettyPrinter(indent=2).pformat([record.message for record in self]) try: if not any(issubclass(w.category, self.expected_warning) for w in self): diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index af32f3506fb..d03004bc76c 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -758,7 +758,20 @@ def test_repr_local_truncated(self) -> None: full_reprlocals = q.repr_locals(loc) assert full_reprlocals is not None assert full_reprlocals.lines - assert full_reprlocals.lines[0] == "l = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]" + assert full_reprlocals.lines[0] == ( + "l = [\n" + " 0,\n" + " 1,\n" + " 2,\n" + " 3,\n" + " 4,\n" + " 5,\n" + " 6,\n" + " 7,\n" + " 8,\n" + " 9,\n" + "]" + ) def test_repr_args_not_truncated(self, importasmod) -> None: mod = importasmod( diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py index 2e3dd55b81f..363c562341c 100644 --- a/testing/io/test_saferepr.py +++ b/testing/io/test_saferepr.py @@ -2,6 +2,7 @@ from __future__ import annotations from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE +from _pytest._io.saferepr import safeformat from _pytest._io.saferepr import SafeRepr from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr_unlimited @@ -13,6 +14,19 @@ def test_simple_repr(): assert saferepr(None) == "None" +def test_safeformat_expands_nested_collections() -> None: + assert safeformat({"outer": [1, {"inner": 2}]}) == ( + "{\n" + " 'outer': [\n" + " 1,\n" + " {\n" + " 'inner': 2,\n" + " },\n" + " ],\n" + "}" + ) + + def test_maxsize(): s = saferepr("x" * 50, maxsize=25) assert len(s) == 25 diff --git a/testing/python/approx.py b/testing/python/approx.py index ed11a1c4ab5..6750b5a0d6a 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -933,6 +933,20 @@ def test_expected_value_type_error(self, x, name): ): approx(x) + def test_nested_mapping_error_uses_expanded_format(self) -> None: + with pytest.raises(TypeError) as excinfo: + approx({"outer": {"inner": 1}}) + + assert str(excinfo.value) == ( + "pytest.approx() does not support nested dictionaries: " + "key='outer' value={'inner': 1}\n" + " full mapping={\n" + " 'outer': {\n" + " 'inner': 1,\n" + " },\n" + "}" + ) + @pytest.mark.parametrize( "x", [ diff --git a/testing/test_assertion.py b/testing/test_assertion.py index e473233b208..34996c8aeeb 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -905,7 +905,9 @@ def test_dict_wrap(self) -> None: "", "Omitting 1 identical items, use -vv to show", "Right contains 1 more item:", - "{'new': 1}", + "{", + " 'new': 1,", + "}", "", "Full diff: (-: missing in left side, +: extra in left side)", " {", @@ -950,7 +952,7 @@ def test_dict_omitting_with_verbosity_2(self) -> None: assert lines is not None assert lines[2].startswith("Common items:") assert "Omitting" not in lines[2] - assert lines[3] == "{'b': 1}" + assert lines[3:6] == ["{", " 'b': 1,", "}"] def test_dict_different_items(self) -> None: lines = callequal({"a": 0}, {"b": 1, "c": 2}, verbose=2) @@ -958,9 +960,14 @@ def test_dict_different_items(self) -> None: "{'a': 0} == {'b': 1, 'c': 2}", "", "Left contains 1 more item:", - "{'a': 0}", + "{", + " 'a': 0,", + "}", "Right contains 2 more items:", - "{'b': 1, 'c': 2}", + "{", + " 'b': 1,", + " 'c': 2,", + "}", "", "Full diff: (-: missing in left side, +: extra in left side)", " {", @@ -976,9 +983,14 @@ def test_dict_different_items(self) -> None: "{'b': 1, 'c': 2} == {'a': 0}", "", "Left contains 2 more items:", - "{'b': 1, 'c': 2}", + "{", + " 'b': 1,", + " 'c': 2,", + "}", "Right contains 1 more item:", - "{'a': 0}", + "{", + " 'a': 0,", + "}", "", "Full diff: (-: missing in left side, +: extra in left side)", " {", @@ -1006,8 +1018,8 @@ def test_dict_extra_items_bounded_under_budget(self) -> None: body = out[1:] assert body == [f"{{{i}: {i}}}" for i in range(5)] # smallest 5, sorted - def test_dict_extra_items_small_keeps_pformat_block(self) -> None: - """Under the budget, the compact pprint block is unchanged.""" + def test_dict_extra_items_small_uses_expanded_format(self) -> None: + """Under the budget, the internal pprint block is expanded.""" out = list( _compare_eq_mapping( {"b": 2, "a": 1}, @@ -1017,7 +1029,13 @@ def test_dict_extra_items_small_keeps_pformat_block(self) -> None: TruncationBudget(max_lines=5, max_chars=350), ) ) - assert out == ["Left contains 2 more items:", "{'a': 1, 'b': 2}"] + assert out == [ + "Left contains 2 more items:", + "{", + " 'a': 1,", + " 'b': 2,", + "}", + ] def test_mapping_different_items(self) -> None: class SimpleMapping(Mapping[str, int]): @@ -1047,7 +1065,9 @@ def __repr__(self) -> str: "Differing items:", "{'a': 0} != {'a': 1}", "Right contains 1 more item:", - "{'c': 2}", + "{", + " 'c': 2,", + "}", "Use -v to get more diff", ] @@ -1248,12 +1268,12 @@ def test_dataclasses(self, pytester: Pytester) -> None: [ "E Omitting 1 identical items, use -vv to show", "E Differing attributes:", - "E ['field_b']", + "E [", + "E 'field_b',", + "E ]", + "E ...", "E ", - "E Drill down into differing attribute field_b:", - "E field_b: 'b' != 'c'", - "E - c", - "E + b", + "E ...Full output truncated, use '-vv' to show", ], consecutive=True, ) @@ -1266,10 +1286,10 @@ def test_recursive_dataclasses(self, pytester: Pytester) -> None: [ "E Omitting 1 identical items, use -vv to show", "E Differing attributes:", - "E ['g', 'h', 'j']", - "E ", - "E Drill down into differing attribute g:", - "E g: S(a=10, b='ten') != S(a=20, b='xxx')...", + "E [", + "E 'g',", + "E 'h',", + "E 'j',...", "E ", "E ...Full output truncated, use '-vv' to show", ], @@ -1283,15 +1303,24 @@ def test_recursive_dataclasses_verbose(self, pytester: Pytester) -> None: result.stdout.fnmatch_lines( [ "E Matching attributes:", - "E ['i']", + "E [", + "E 'i',", + "E ]", "E Differing attributes:", - "E ['g', 'h', 'j']", + "E [", + "E 'g',", + "E 'h',", + "E 'j',", + "E ]", "E ", "E Drill down into differing attribute g:", "E g: S(a=10, b='ten') != S(a=20, b='xxx')", "E ", "E Differing attributes:", - "E ['a', 'b']", + "E [", + "E 'a',", + "E 'b',", + "E ]", "E ", "E Drill down into differing attribute a:", "E a: 10 != 20", @@ -1422,7 +1451,7 @@ class SimpleDataObject: assert lines is not None assert lines[2].startswith("Matching attributes:") assert "Omitting" not in lines[2] - assert lines[3] == "['field_a']" + assert lines[3:6] == ["[", " 'field_a',", "]"] def test_attrs_with_attribute_comparison_off(self) -> None: @attr.s @@ -1437,7 +1466,7 @@ class SimpleDataObject: assert lines is not None assert lines[2].startswith("Matching attributes:") assert "Omitting" not in lines[1] - assert lines[3] == "['field_a']" + assert lines[3:6] == ["[", " 'field_a',", "]"] for line in lines[3:]: assert "field_b" not in line @@ -1504,7 +1533,9 @@ class NT(NamedTuple): "", "Omitting 1 identical items, use -vv to show", "Differing attributes:", - "['b']", + "[", + " 'b',", + "]", "", "Drill down into differing attribute b:", " b: 'b' != 'c'", @@ -2832,11 +2863,17 @@ def test(): """, [ "{bold}{red}E Common items:{reset}", - "{bold}{red}E {reset}{{{str}'{hl-reset}{str}number-is-1{hl-reset}{str}'{hl-reset}: {number}1*", + "{bold}{red}E {reset}{{*", + "{bold}{red}E {reset} {str}'{hl-reset}{str}" + "number-is-1{hl-reset}{str}'{hl-reset}: {number}1*", "{bold}{red}E Left contains 1 more item:{reset}", - "{bold}{red}E {reset}{{{str}'{hl-reset}{str}number-is-5{hl-reset}{str}'{hl-reset}: {number}5*", + "{bold}{red}E {reset}{{*", + "{bold}{red}E {reset} {str}'{hl-reset}{str}" + "number-is-5{hl-reset}{str}'{hl-reset}: {number}5*", "{bold}{red}E Right contains 1 more item:{reset}", - "{bold}{red}E {reset}{{{str}'{hl-reset}{str}number-is-0{hl-reset}{str}'{hl-reset}: {number}0*", + "{bold}{red}E {reset}{{*", + "{bold}{red}E {reset} {str}'{hl-reset}{str}" + "number-is-0{hl-reset}{str}'{hl-reset}: {number}0*", "{bold}{red}E {reset}{light-gray} {hl-reset} {{{endline}{reset}", "{bold}{red}E {reset}{light-gray} {hl-reset} 'number-is-1': 1,{endline}{reset}", "{bold}{red}E {reset}{light-green}+ 'number-is-5': 5,{hl-reset}{endline}{reset}", diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index f84e03491fe..398ede16c0f 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -273,6 +273,7 @@ def test_cache_show(pytester: Pytester) -> None: def pytest_configure(config): config.cache.set("my/name", [1,2,3]) config.cache.set("my/hello", "world") + config.cache.set("my/nested", {"level": {"values": [1, 2]}}) config.cache.set("other/some", {1:2}) dp = config.cache.mkdir("mydb") dp.joinpath("hello").touch() @@ -289,9 +290,24 @@ def pytest_configure(config): "*- cache values for '[*]' -*", "cache/nodeids contains:", "my/name contains:", - " [1, 2, 3]", + " [", + " 1,", + " 2,", + " 3,", + " ]", + "my/nested contains:", + " {", + " 'level': {", + " 'values': [", + " 1,", + " 2,", + " ],", + " },", + " }", "other/some contains:", - " {*'1': 2}", + " {", + " '1': 2,", + " }", "*- cache directories for '[*]' -*", "*mydb/hello*length 0*", "*mydb/world*length 0*", diff --git a/testing/test_error_diffs.py b/testing/test_error_diffs.py index 653559c8a99..fc6d2583e6a 100644 --- a/testing/test_error_diffs.py +++ b/testing/test_error_diffs.py @@ -134,11 +134,17 @@ def test_this(): > assert result == expected E AssertionError: assert {1: 'spam', 3: 'eggs'} == {1: 'spam', 2: 'eggs'} E Common items: - E {1: 'spam'} + E { + E 1: 'spam', + E } E Left contains 1 more item: - E {3: 'eggs'} + E { + E 3: 'eggs', + E } E Right contains 1 more item: - E {2: 'eggs'} + E { + E 2: 'eggs', + E } E Full diff: (-: missing in left side, +: extra in left side) E { E 1: 'spam', @@ -161,7 +167,9 @@ def test_this(): > assert result == expected E AssertionError: assert {1: 'spam', 2: 'eggs'} == {1: 'spam', 2: 'bacon'} E Common items: - E {1: 'spam'} + E { + E 1: 'spam', + E } E Differing items: E {2: 'eggs'} != {2: 'bacon'} E Full diff: (-: missing in left side, +: extra in left side) @@ -184,11 +192,17 @@ def test_this(): > assert result == expected E AssertionError: assert {1: 'spam', 2: 'eggs'} == {1: 'spam', 3: 'bacon'} E Common items: - E {1: 'spam'} + E { + E 1: 'spam', + E } E Left contains 1 more item: - E {2: 'eggs'} + E { + E 2: 'eggs', + E } E Right contains 1 more item: - E {3: 'bacon'} + E { + E 3: 'bacon', + E } E Full diff: (-: missing in left side, +: extra in left side) E { E 1: 'spam', @@ -248,9 +262,13 @@ def test_this(): > assert result == expected E AssertionError: assert A(a=1, b='spam') == A(a=2, b='spam') E Matching attributes: - E ['b'] + E [ + E 'b', + E ] E Differing attributes: - E ['a'] + E [ + E 'a', + E ] E Drill down into differing attribute a: E a: 1 != 2 """, @@ -274,9 +292,13 @@ def test_this(): > assert result == expected E AssertionError: assert A(a=1, b='spam') == A(a=1, b='eggs') E Matching attributes: - E ['a'] + E [ + E 'a', + E ] E Differing attributes: - E ['b'] + E [ + E 'b', + E ] E Drill down into differing attribute b: E b: 'spam' != 'eggs' E - eggs diff --git a/testing/test_recwarn.py b/testing/test_recwarn.py index d6de42a4c5c..a68c27dee6c 100644 --- a/testing/test_recwarn.py +++ b/testing/test_recwarn.py @@ -302,7 +302,7 @@ def test_as_contextmanager(self) -> None: warnings.warn("user", UserWarning) excinfo.match( r"DID NOT WARN. No warnings of type \(.+RuntimeWarning.+,\) were emitted.\n" - r" Emitted warnings: \[UserWarning\('user',?\)\]." + r" Emitted warnings: \[\n UserWarning\('user',?\),\n\]." ) with pytest.warns(): @@ -311,7 +311,7 @@ def test_as_contextmanager(self) -> None: warnings.warn("runtime", RuntimeWarning) excinfo.match( r"DID NOT WARN. No warnings of type \(.+UserWarning.+,\) were emitted.\n" - r" Emitted warnings: \[RuntimeWarning\('runtime',?\)]." + r" Emitted warnings: \[\n RuntimeWarning\('runtime',?\),\n\]." ) with pytest.raises(pytest.fail.Exception) as excinfo: @@ -325,14 +325,16 @@ def test_as_contextmanager(self) -> None: warning_classes = (UserWarning, FutureWarning) with pytest.warns(): with pytest.raises(pytest.fail.Exception) as excinfo: - with pytest.warns(warning_classes) as warninfo: + with pytest.warns(warning_classes): warnings.warn("runtime", RuntimeWarning) warnings.warn("import", ImportWarning) - messages = [each.message for each in warninfo] expected_str = ( f"DID NOT WARN. No warnings of type {warning_classes} were emitted.\n" - f" Emitted warnings: {messages}." + " Emitted warnings: [\n" + " RuntimeWarning('runtime'),\n" + " ImportWarning('import'),\n" + "]." ) assert str(excinfo.value) == expected_str