From e394a8a960e6e7c6dd17092806415873588f869d Mon Sep 17 00:00:00 2001 From: daniell-olaitan Date: Tue, 20 Jan 2026 13:52:34 +0100 Subject: [PATCH] Preserve dictionary insertion order in assertion output Fixes #13503 - Remove alphabetical sorting from PrettyPrinter dict methods - Update assertion util to preserve original dict key order - Add comprehensive test for insertion order preservation - Update existing tests to match new multiline dict formatting Dictionary keys are now displayed in insertion order (guaranteed since Python 3.7) instead of alphabetically, making debug output more intuitive and matching behavior of print(). --- src/_pytest/_io/pprint.py | 6 ++- src/_pytest/assertion/util.py | 15 ++++-- testing/test_assertion.py | 92 +++++++++++++++++++++++++++++++---- 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/_pytest/_io/pprint.py b/src/_pytest/_io/pprint.py index 28f06909206..7e1d221c1e9 100644 --- a/src/_pytest/_io/pprint.py +++ b/src/_pytest/_io/pprint.py @@ -162,7 +162,8 @@ def _pprint_dict( ) -> None: write = stream.write write("{") - items = sorted(object.items(), key=_safe_tuple) + # Preserve insertion order (guaranteed since Python 3.7) + items = object.items() self._format_dict_items(items, stream, indent, allowance, context, level) write("}") @@ -608,7 +609,8 @@ def _safe_repr( components: list[str] = [] append = components.append level += 1 - for k, v in sorted(object.items(), key=_safe_tuple): + # Preserve insertion order (guaranteed since Python 3.7) + for k, v in object.items(): krepr = self._safe_repr(k, context, maxlevels, level) vrepr = self._safe_repr(v, context, maxlevels, level) append(f"{krepr}: {vrepr}") diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index f35d83a6fe4..2aec85d27f5 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -510,7 +510,8 @@ def _compare_eq_dict( explanation += [f"Omitting {len(same)} identical items, use -vv to show"] elif same: explanation += ["Common items:"] - explanation += highlighter(pprint.pformat(same)).splitlines() + # Use custom PrettyPrinter to preserve insertion order + explanation += highlighter(PrettyPrinter().pformat(same)).splitlines() diff = {k for k in common if left[k] != right[k]} if diff: explanation += ["Differing items:"] @@ -526,8 +527,11 @@ def _compare_eq_dict( explanation.append( f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:" ) + # Preserve insertion order from the original dict - use custom PrettyPrinter explanation.extend( - highlighter(pprint.pformat({k: left[k] for k in extra_left})).splitlines() + highlighter( + PrettyPrinter().pformat({k: left[k] for k in left if k in extra_left}) + ).splitlines() ) extra_right = set_right - set_left len_extra_right = len(extra_right) @@ -535,8 +539,13 @@ def _compare_eq_dict( explanation.append( f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:" ) + # Preserve insertion order from the original dict - use custom PrettyPrinter explanation.extend( - highlighter(pprint.pformat({k: right[k] for k in extra_right})).splitlines() + highlighter( + PrettyPrinter().pformat( + {k: right[k] for k in right if k in extra_right} + ) + ).splitlines() ) return explanation diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 9c9881cf8ed..14ee2c7fb5e 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -696,7 +696,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:", " {", @@ -741,7 +743,10 @@ 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}" + # Common items now formatted with proper indentation across multiple lines + assert lines[3] == "{" + assert lines[4] == " 'b': 1," + assert lines[5] == "}" def test_dict_different_items(self) -> None: lines = callequal({"a": 0}, {"b": 1, "c": 2}, verbose=2) @@ -749,9 +754,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:", " {", @@ -767,9 +777,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:", " {", @@ -781,6 +796,61 @@ def test_dict_different_items(self) -> None: " }", ] + def test_dict_insertion_order_preserved(self) -> None: + """Test that dictionary insertion order is preserved in output (issue #13503).""" + # Create dicts with keys that would differ in alphabetical vs insertion order + left_dict = { + "zebra": 1, + "apple": 2, + "mango": 3, + } + right_dict: dict[str, int] = {} + + lines = callequal(left_dict, right_dict, verbose=2) + assert lines is not None + + # The "Left contains" section should preserve insertion order (zebra, apple, mango) + # NOT alphabetical order (apple, mango, zebra) + left_section = "\n".join(lines) + + # Find the position of each key in the output + zebra_pos = left_section.find("'zebra'") + apple_pos = left_section.find("'apple'") + mango_pos = left_section.find("'mango'") + + # All keys should appear + assert zebra_pos != -1 + assert apple_pos != -1 + assert mango_pos != -1 + + # Insertion order: zebra should come before apple, apple before mango + assert zebra_pos < apple_pos, "Expected zebra before apple (insertion order)" + assert apple_pos < mango_pos, "Expected apple before mango (insertion order)" + + # Test with right dict having extra items + left_dict2: dict[str, str] = {} + right_dict2 = { + "zulu": "a", + "alpha": "b", + "mike": "c", + } + + lines2 = callequal(left_dict2, right_dict2, verbose=2) + assert lines2 is not None + + right_section = "\n".join(lines2) + zulu_pos = right_section.find("'zulu'") + alpha_pos = right_section.find("'alpha'") + mike_pos = right_section.find("'mike'") + + assert zulu_pos != -1 + assert alpha_pos != -1 + assert mike_pos != -1 + + # Insertion order: zulu, alpha, mike + assert zulu_pos < alpha_pos, "Expected zulu before alpha (insertion order)" + assert alpha_pos < mike_pos, "Expected alpha before mike (insertion order)" + def test_sequence_different_items(self) -> None: lines = callequal((1, 2), (3, 4, 5), verbose=2) assert lines == [ @@ -2070,12 +2140,16 @@ def test(): } """, [ + # Common items are now formatted with multi-line indentation "{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}{{{endline}{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}{{{endline}{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}{{{endline}{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 {light-gray} {hl-reset} 'number-is-1': 1,{endline}{reset}", "{bold}{red}E {light-green}+ 'number-is-5': 5,{hl-reset}{endline}{reset}",