From e1a94c1d03416ecc4599b2758cb4ce5eca8f4ebd Mon Sep 17 00:00:00 2001 From: DavidAG Date: Wed, 17 Dec 2025 02:11:11 +0100 Subject: [PATCH 1/2] Fix: assertrepr_compare should respect dictionary insertion order (Closes #13503) --- changelog/14050.bugfix.rst | 1 + src/_pytest/_io/saferepr.py | 25 +++++++++++++++++++++++++ testing/io/test_saferepr.py | 28 ++++++++++++++++++++++++++++ testing/test_assertion.py | 2 +- 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 changelog/14050.bugfix.rst diff --git a/changelog/14050.bugfix.rst b/changelog/14050.bugfix.rst new file mode 100644 index 00000000000..9642a0941fa --- /dev/null +++ b/changelog/14050.bugfix.rst @@ -0,0 +1 @@ +Assertion comparison output now preserves dictionary insertion order instead of sorting keys. diff --git a/src/_pytest/_io/saferepr.py b/src/_pytest/_io/saferepr.py index cee70e332f9..3f5c956d9cf 100644 --- a/src/_pytest/_io/saferepr.py +++ b/src/_pytest/_io/saferepr.py @@ -1,5 +1,6 @@ from __future__ import annotations +from itertools import islice import pprint import reprlib @@ -77,8 +78,32 @@ def repr_instance(self, x: object, level: int) -> str: s = _format_repr_exception(exc, x) if self.maxsize is not None: s = _ellipsize(s, self.maxsize) + return s + def repr_dict(self, x: dict[object, object], level: int) -> str: + """Represent a dict while preserving its insertion order. + + Differs from ``reprlib.Repr.repr_dict`` by iterating directly over ``x`` + rather than using the stdlib's sorting helper. + """ + fillvalue = "..." + n = len(x) + if n == 0: + return "{}" + if level <= 0: + return "{" + fillvalue + "}" + newlevel = level - 1 + repr1 = self.repr1 + pieces = [] + for key in islice(x, self.maxdict): + keyrepr = repr1(key, newlevel) + valrepr = repr1(x[key], newlevel) + pieces.append(f"{keyrepr}: {valrepr}") + if n > self.maxdict: + pieces.append(fillvalue) + return "{" + ", ".join(pieces) + "}" + def safeformat(obj: object) -> str: """Return a pretty printed string for the given object. diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py index 075d40cdf44..5812b78f22a 100644 --- a/testing/io/test_saferepr.py +++ b/testing/io/test_saferepr.py @@ -192,3 +192,31 @@ def __repr__(self): assert saferepr_unlimited(A()).startswith( "<[ValueError(42) raised in repr()] A object at 0x" ) + + +def test_saferepr_dict_preserves_insertion_order(): + d = {"b": 1, "a": 2} + assert saferepr(d, maxsize=None) == "{'b': 1, 'a': 2}" + + +def test_saferepr_dict_truncation_preserves_insertion_order(): + from _pytest._io.saferepr import SafeRepr + + d = {"b": 1, "a": 2} + s = SafeRepr(maxsize=None) + s.maxdict = 1 + assert s.repr(d) == "{'b': 1, ...}" + + +def test_saferepr_dict_fillvalue_when_level_is_zero(): + from _pytest._io.saferepr import SafeRepr + + s = SafeRepr(maxsize=None) + assert s.repr_dict({"a": 1}, level=0) == "{...}" + + +def test_saferepr_dict_empty(): + from _pytest._io.saferepr import SafeRepr + + s = SafeRepr(maxsize=None) + assert s.repr_dict({}, level=1) == "{}" diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 5179b13b0e9..49b8cbd6f06 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -129,7 +129,7 @@ def test_dummy_failure(pytester): # how meta! [ "> r.assertoutcome(passed=1)", "E AssertionError: ([[][]], [[][]], [[][]])*", - "E assert {'failed': 1,... 'skipped': 0} == {'failed': 0,... 'skipped': 0}", + "E assert {'passed': 0,...*'failed': 1} == {'passed': 1,...*'failed': 0}", "E Omitting 1 identical items, use -vv to show", "E Differing items:", "E Use -v to get more diff", From dcb31a40571847a4887ba95e3fc5b8b979e7560b Mon Sep 17 00:00:00 2001 From: DavidAG Date: Fri, 13 Feb 2026 00:17:57 +0100 Subject: [PATCH 2/2] Address PR 14050 review comments --- changelog/14050.bugfix.rst | 2 +- src/_pytest/_io/pprint.py | 4 +-- src/_pytest/pytester_assertions.py | 4 +-- testing/io/test_saferepr.py | 41 ++++++++++++++++++------------ testing/test_assertion.py | 33 +++++++++++++++++++++++- 5 files changed, 62 insertions(+), 22 deletions(-) diff --git a/changelog/14050.bugfix.rst b/changelog/14050.bugfix.rst index 9642a0941fa..451b248576f 100644 --- a/changelog/14050.bugfix.rst +++ b/changelog/14050.bugfix.rst @@ -1 +1 @@ -Assertion comparison output now preserves dictionary insertion order instead of sorting keys. +Display dictionary differences in assertion failures using the original key insertion order instead of sorted order. diff --git a/src/_pytest/_io/pprint.py b/src/_pytest/_io/pprint.py index 28f06909206..ec41b449ddf 100644 --- a/src/_pytest/_io/pprint.py +++ b/src/_pytest/_io/pprint.py @@ -162,7 +162,7 @@ def _pprint_dict( ) -> None: write = stream.write write("{") - items = sorted(object.items(), key=_safe_tuple) + items = object.items() self._format_dict_items(items, stream, indent, allowance, context, level) write("}") @@ -608,7 +608,7 @@ def _safe_repr( components: list[str] = [] append = components.append level += 1 - for k, v in sorted(object.items(), key=_safe_tuple): + 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/pytester_assertions.py b/src/_pytest/pytester_assertions.py index 915cc8a10ff..b8d8a195241 100644 --- a/src/_pytest/pytester_assertions.py +++ b/src/_pytest/pytester_assertions.py @@ -26,11 +26,11 @@ def assertoutcome( realpassed, realskipped, realfailed = outcomes obtained = { + "failed": len(realfailed), "passed": len(realpassed), "skipped": len(realskipped), - "failed": len(realfailed), } - expected = {"passed": passed, "skipped": skipped, "failed": failed} + expected = {"failed": failed, "passed": passed, "skipped": skipped} assert obtained == expected, outcomes diff --git a/testing/io/test_saferepr.py b/testing/io/test_saferepr.py index 5812b78f22a..2e3dd55b81f 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 SafeRepr from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr_unlimited import pytest @@ -194,29 +195,37 @@ def __repr__(self): ) -def test_saferepr_dict_preserves_insertion_order(): - d = {"b": 1, "a": 2} - assert saferepr(d, maxsize=None) == "{'b': 1, 'a': 2}" - - -def test_saferepr_dict_truncation_preserves_insertion_order(): - from _pytest._io.saferepr import SafeRepr - - d = {"b": 1, "a": 2} +def test_saferepr_dict_preserves_insertion_order() -> None: + d = { + "b": 2, + "a": 1, + "d": 4, + "e": 5, + "c": 3, + } + s = SafeRepr(maxsize=None) + s.maxdict = 10 + assert s.repr(d) == "{'b': 2, 'a': 1, 'd': 4, 'e': 5, 'c': 3}" + + +def test_saferepr_dict_truncation_preserves_insertion_order() -> None: + d = { + "b": 2, + "a": 1, + "d": 4, + "e": 5, + "c": 3, + } s = SafeRepr(maxsize=None) s.maxdict = 1 - assert s.repr(d) == "{'b': 1, ...}" + assert s.repr(d) == "{'b': 2, ...}" -def test_saferepr_dict_fillvalue_when_level_is_zero(): - from _pytest._io.saferepr import SafeRepr - +def test_saferepr_dict_fillvalue_when_level_is_zero() -> None: s = SafeRepr(maxsize=None) assert s.repr_dict({"a": 1}, level=0) == "{...}" -def test_saferepr_dict_empty(): - from _pytest._io.saferepr import SafeRepr - +def test_saferepr_dict_empty() -> None: s = SafeRepr(maxsize=None) assert s.repr_dict({}, level=1) == "{}" diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 49b8cbd6f06..12351a48a7f 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -129,7 +129,7 @@ def test_dummy_failure(pytester): # how meta! [ "> r.assertoutcome(passed=1)", "E AssertionError: ([[][]], [[][]], [[][]])*", - "E assert {'passed': 0,...*'failed': 1} == {'passed': 1,...*'failed': 0}", + "E assert {'failed': 1,... 'skipped': 0} == {'failed': 0,... 'skipped': 0}", "E Omitting 1 identical items, use -vv to show", "E Differing items:", "E Use -v to get more diff", @@ -2198,3 +2198,34 @@ def test_vvv(): ] ) result.stdout.no_fnmatch_line(expected_non_vvv_arg_line) + + +def test_dict_extra_items_preserve_insertion_order(pytester: Pytester) -> None: + """Assertion output of dict diff shows keys in insertion order (#13503).""" + pytester.makepyfile( + test_order=""" + def test_order(): + a = { + "b": 2, + "a": 1, + "d": 4, + "e": 5, + "c": 3, + } + assert a == {} + """ + ) + + result = pytester.runpytest("-vv") + result.stdout.fnmatch_lines( + [ + "*Left contains 5 more items:*", + "*Full diff:", + "* + *'b': 2,", + "* + *'a': 1,", + "* + *'d': 4,", + "* + *'e': 5,", + "* + *'c': 3,", + "test_order.py:*: AssertionError", + ] + )