Skip to content

Better reporting for "dict subset" comparison #12860

Open
@nikitagashkov

Description

@nikitagashkov

Preface

Python has a way of checking that one dict is a subset of another one, as highlighed in #2376 (comment). This is quite a nifty feature that allows for partial checks when you don't want to check all the fields e.g., some of them are not stable, so you want to split the checks:

def test_unstable():
    dict = {"a": 42, "b": random.random()}

    assert dict.items() >= {"a": 42}.items()
    assert 0 < dict["b"] <= 1

or the dict has too many irrelevant items and you don't want to list them all just to check a couple of interesting ones:

def test_irrelevant():
    dict = requests.get("https://example.com/big-json-with-lots-of-fields")

    assert dict.items() >= {"a": 42, "b": "a113"}.items()

What's the problem this feature will solve?

Described approach works perfectly fine from the functional standpoint however since there is no dedicated handling of this case, reporting fallbacks to repr of the ItemsView:

    def test_big_left():
        left = {"a": 1, "b": 2, "c": 3, "d": 4}
        right = left.copy() | {"e": 5}

>       assert left.items() >= right.items()
E       AssertionError: assert dict_items([('a', 1), ('b', 2), ('c', 3), ('d', 4)]) >= dict_items([('a', 1), ('b', 2), ('c', 3), ('d', 4), ('e', 5)])
E        +  where dict_items([('a', 1), ('b', 2), ('c', 3), ('d', 4)]) = <built-in method items of dict object at 0x103a65bc0>()
E        +    where <built-in method items of dict object at 0x103a65bc0> = {'a': 1, 'b': 2, 'c': 3, 'd': 4}.items
E        +  and   dict_items([('a', 1), ('b', 2), ('c', 3), ('d', 4), ('e', 5)]) = <built-in method items of dict object at 0x103a65ec0>()
E        +    where <built-in method items of dict object at 0x103a65ec0> = {'a': 1, 'b': 2, 'c': 3, 'd': 4, ...}.items

It would be nice to have a dedicated comparison for this that shows only the difference, similar to dict comparison:

    def test_repr():
        left = {"a": 1, "b": 2, "c": 3, "d": 4}
        right = left.copy() | {"e": 5}

>       assert left == right
E       AssertionError: assert {'a': 1, 'b':...c': 3, 'd': 4} == {'a': 1, 'b':..., 'd': 4, ...}
E
E         Omitting 4 identical items, use -vv to show
E         Right contains 1 more item:
E         {'e': 5}
E         Use -v to get more diff

Describe the solution you'd like

Currently, I have a naive implementation for such case that utilizes pytest_assertrepr_compare hook:

def pytest_assertrepr_compare(
    config: Config,
    op: str,
    left: object,
    right: object,
) -> list[str] | None:
    if (
        isinstance(left, ItemsView)
        and isinstance(right, ItemsView)
        and
        # Naive implementation for superset comparison only. XXX: Support other
        # comparisons?
        op == ">="
    ):
        missing: list[str] = []
        differing: list[str] = []

        left_dict = dict(left)
        for k, v in right:
            # XXX: Shouldn't `k` and `v` go through `pytest_assertrepr_compare` as well?
            if k not in left_dict:
                missing.append(f"  {k!r}: {v!r}")
            elif left_dict[k] != v:
                differing.append(f"  {k!r}: {left_dict[k]!r} != {v!r}")
        assert missing or differing  # Otherwise, why are we even here?

        # XXX: Better header?
        output = ["left dict_items(...) is not a superset of right dict_items(...)"]
        if missing:
            output.append("Missing:")
            output.extend(missing)
        if differing:
            output.append("Differing:")
            output.extend(differing)

        return output

    return None

With this hook in place, the output looks like this:

    def test_repr():
        left = {"a": 1, "b": 2, "c": 3, "d": 4}
        right = left.copy() | {"e": 5}

>       assert left.items() >= right.items()
E       AssertionError: assert left dict_items(...) is not a superset of right dict_items(...)
E         Missing:
E           'e': 5

and for a mismatch:

    def test_repr():
        left = {"a": 1, "b": 2, "c": 3, "d": 4}
        right = left.copy() | {"a": 12}

>       assert left.items() >= right.items()
E       AssertionError: assert left dict_items(...) is not a superset of right dict_items(...)
E         Differing:
E           'a': 1 != 12

Alternative Solutions

I think it's possible to introduce rich comparison with third-party plugins that implement the hook similar to aforementioned or we can introduce some unittest-style assertDictIsSubset that handles reporting, but to be honest I'm leaning towards thinking that first-party support via assertrepr for this would be best.

Additional context

Metadata

Metadata

Assignees

No one assigned

    Labels

    topic: rewriterelated to the assertion rewrite mechanismtype: enhancementnew feature or API change, should be merged into features branch

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions