tophat / syrupy

:pancakes: The sweeter pytest snapshot plugin
https://tophat.github.io/syrupy
Apache License 2.0
501 stars 33 forks source link

"exclude" filters don't appear to work correctly with msgspec structs #784

Closed BenGatewood closed 11 months ago

BenGatewood commented 11 months ago

Describe the bug

When I try and exclude attributes from a custom class that inherits from msgspec.Struct the syntax/approach from the docs doesn't seem to produce the desired behaviour and the excluded attributes remain in the snapshot

To reproduce


from syrupy.filters import props

class Base(msgspec.Struct):
    foo: str
    bar: int

def test_base(snapshot):
    b = Base(
        foo="baz",
        bar=5,
    )

    assert b == snapshot(exclude=props("bar"))

Expected behavior

From the above example, I would expect a snapshot like:

# name: test_base
  Base(foo='baz')
# ---

Environment (please complete the following information):

Additional context

Thanks for looking!

noahnu commented 11 months ago

Custom classes are serialized by calling repr, see: https://github.com/tophat/syrupy/blob/2ed84ea8e2b9dfa01cd4788f9787809ffc6b2648/src/syrupy/extensions/amber/serializer.py#L373

Since repr returns a string, we don't have a way to modify the result of calling repr to apply an exclude. You could extend the serializer to support msgspec.Struct:

import pytest
from typing import Any
from syrupy.filters import props
from syrupy.extensions.amber import AmberSnapshotExtension, AmberDataSerializer

import msgspec

class MySpec(msgspec.Struct):
    a: str
    b: int

class ExtendedAmberDataSerializer(AmberDataSerializer):
    @classmethod
    def serialize_unknown(cls, data: Any, *, depth: int = 0, **kwargs: Any) -> str:
        if isinstance(data, (msgspec.Struct,)):
            return cls.serialize_custom_iterable(
                data=data,
                resolve_entries=(
                    cls.sort(data.__struct_fields__),
                    lambda o, p: getattr(o, str(p)),
                    None
                ),
                separator="=",
                **kwargs
            )
        return super().serialize_unknown(data, depth=depth, **kwargs)

class ExtendedAmberSnapshotExtension(AmberSnapshotExtension):
    serializer_class = ExtendedAmberDataSerializer

@pytest.fixture
def snapshot(snapshot):
    return snapshot.use_extension(ExtendedAmberSnapshotExtension)

def test_case(snapshot):
    assert MySpec(a="word", b=3) == snapshot(exclude=props("a"))

I acknowledge the serializer can be improved here.. I think we could expose an alternative to serialize_dict to handle repr a bit better


Note that "serialize_custom_iterable" requires syrupy v4.1.0 (just released today).

noahnu commented 11 months ago

Updated my example using serialize_custom_iterable which I've exposed in v4.1.0.

BenGatewood commented 11 months ago

Nice one - thanks! I'll have a go with this method for the moment

noahnu commented 11 months ago

Will close out this issue then. Feel free to comment and I'll re-open if there are other ways we can make this simpler.