Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better reporting for "dict subset" comparison #12860

Open
nikitagashkov opened this issue Oct 7, 2024 · 0 comments
Open

Better reporting for "dict subset" comparison #12860

nikitagashkov opened this issue Oct 7, 2024 · 0 comments
Labels
topic: rewrite related to the assertion rewrite mechanism type: enhancement new feature or API change, should be merged into features branch

Comments

@nikitagashkov
Copy link

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

@Zac-HD Zac-HD added type: enhancement new feature or API change, should be merged into features branch topic: rewrite related to the assertion rewrite mechanism labels Oct 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: rewrite related to the assertion rewrite mechanism type: enhancement new feature or API change, should be merged into features branch
Projects
None yet
Development

No branches or pull requests

2 participants