diff --git a/AUTHORS b/AUTHORS index 136ffd6c5ee..c20b4898fce 100644 --- a/AUTHORS +++ b/AUTHORS @@ -54,6 +54,7 @@ Anton Zhilin Antony Lee Arel Cordero Arias Emmanuel +Ariel Even Ariel Pillemer Armin Rigo Aron Coyle diff --git a/changelog/15310.bugfix.rst b/changelog/15310.bugfix.rst new file mode 100644 index 00000000000..1ef38763231 --- /dev/null +++ b/changelog/15310.bugfix.rst @@ -0,0 +1 @@ +Fixed the assertion diff for mappings whose ``keys()`` returns a list, such as ``starlette.datastructures.Headers``, which failed with a ``TypeError`` since :pr:`14463`. diff --git a/src/_pytest/assertion/_compare_mapping.py b/src/_pytest/assertion/_compare_mapping.py index 9b7f392d9a9..31e1b71302e 100644 --- a/src/_pytest/assertion/_compare_mapping.py +++ b/src/_pytest/assertion/_compare_mapping.py @@ -2,7 +2,9 @@ from collections.abc import Collection from collections.abc import Iterator +from collections.abc import KeysView from collections.abc import Mapping +from collections.abc import Set as AbstractSet import heapq import pprint @@ -13,6 +15,17 @@ from _pytest.assertion._typing import TruncationBudget +def _keys_view(mapping: Mapping[object, object]) -> AbstractSet[object]: + """Return a set-like view of ``mapping``'s keys. + + Some mappings override ``keys()`` to return a plain list (for example + ``starlette.datastructures.Headers``); wrap those in a ``KeysView``, which + still resolves membership through the mapping. + """ + keys = mapping.keys() + return keys if isinstance(keys, AbstractSet) else KeysView(mapping) + + def _compare_eq_mapping( left: Mapping[object, object], right: Mapping[object, object], @@ -24,8 +37,8 @@ def _compare_eq_mapping( # operations resolve membership through the mappings themselves, so a # mapping with its own key equivalence (case-insensitive keys, for # example) is compared the way it compares itself. - keys_left = left.keys() - keys_right = right.keys() + keys_left = _keys_view(left) + keys_right = _keys_view(right) common = keys_left & keys_right same = {k: left[k] for k in common if left[k] == right[k]} if same and verbose < 2: diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 035aad31c54..4635d5cf28d 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -1116,6 +1116,44 @@ def __len__(self) -> int: # pragma: no cover "Use -v to get more diff", ] + def test_mapping_with_list_keys(self) -> None: + """A mapping whose ``keys()`` returns a list still gets the mapping diff. + + ``starlette.datastructures.Headers`` is such a mapping: it overrides + ``keys()`` to return a list, which has no set operations. + """ + + class ListKeysMapping(Mapping[str, int]): + def __init__(self, values: dict[str, int]) -> None: + self._values = values + + def __getitem__(self, key: str) -> int: + return self._values[key] + + def __iter__(self) -> Iterator[str]: + return iter(self._values) + + def __len__(self) -> int: # pragma: no cover + return len(self._values) + + def keys(self) -> list[str]: # type: ignore[override] + return list(self._values) + + lines = callequal( + ListKeysMapping({"a": 0, "b": 1}), + ListKeysMapping({"a": 1, "b": 1, "c": 2}), + ) + + assert lines is not None + assert lines[2:] == [ + "Omitting 1 identical items, use -vv to show", + "Differing items:", + "{'a': 0} != {'a': 1}", + "Right contains 1 more item:", + "{'c': 2}", + "Use -v to get more diff", + ] + def test_sequence_different_items(self) -> None: lines = callequal((1, 2), (3, 4, 5), verbose=2) assert lines == [