python-attrs / cattrs

Composable custom class converters for attrs, dataclasses and friends.
https://catt.rs
MIT License
779 stars 108 forks source link

Fields with init=False Don't Get Serialized #545

Closed akefirad closed 1 month ago

akefirad commented 1 month ago

Description

I'm trying to serialize a data class with some fields with init=False, but they don't show up in the output of unstructure method.

What I Did

Here's my code snippet:

@frozen
class ClassWithInitFalse:
    a: int
    b: str
    d: int = field(init=False, default=2)

def test_class_with_init_false():
    foo = ClassWithInitFalse(a=1, b="b")
    serialized = cattrs.unstructure(foo)
    assert serialized == {"a": 1, "b": "b", "d": 2}

But I'm not sure if understand how such fields are being dealt with in cattrs. I saw some discussions and issues and pull-requests, but could find specifically why they don't show up in the unstructured output or how to fix it. (There's some docs about how to deal with them while deserializing, but my question is about serialization)

Sorry if it's a duplicate issue! Is there any way to include init-fields automatically in serialization? If not, is there's any discussion about the reasoning behind this behavior? (Using hooks is not idea, since I have many classes with such fields mainly tag for union types as other people mentioned as a common use case in other issues).

If it's not supported and if it's possible, can we support this?

Thanks.

Tinche commented 1 month ago

Howdy!

Yeah, like you've noticed, cattrs skips init=False fields by default.

This can be changed on a case-by-case basis by generating and registering an unstructure hook for a class.

from cattrs import Converter
from cattrs.gen import make_dict_unstructure_fn

def test_class_with_init_false():
    foo = ClassWithInitFalse(a=1, b="b")

    conv = cattrs.Converter()
    conv.register_unstructure_hook(
        ClassWithInitFalse,
        make_dict_unstructure_fn(
            ClassWithInitFalse, conv, _cattrs_include_init_false=True
        ),
    )

    serialized = conv.unstructure(foo)
    assert serialized == {"a": 1, "b": "b", "d": 2}

This can be applied wider (for example, to all attrs classes) using a hook factory that's similar to this. That's a little more complex but I can show you how if you need to.

I'm going to close this now to keep the issue list tidy, let me know if you have further questions!

akefirad commented 1 month ago

Thanks @Tinche for the reply. Yes, I’d like to enable it for all classes in my app. (Out of curiosity; why is it not enabled by default for all?) So I’d appreciate if you show how it can be done (or point me to docs if there’s any for this) Thanks.

Tinche commented 4 weeks ago

Yes, I’d like to enable it for all classes in my app.

Instead of a single unstructure hook for ClassWithInitFalse, we're going to define an unstructure hook factory for all attrs classes. Here's the code, it looks somewhat similar to the single-class case:

from attrs import field, frozen, has

import cattrs
from cattrs.gen import make_dict_unstructure_fn

@frozen
class ClassWithInitFalse:
    a: int
    b: str
    d: int = field(init=False, default=2)

def test_class_with_init_false():
    foo = ClassWithInitFalse(a=1, b="b")

    conv = cattrs.Converter()
    conv.register_unstructure_hook_factory(
        has,
        lambda t: make_dict_unstructure_fn(t, conv, _cattrs_include_init_false=True),
    )

    serialized = conv.unstructure(foo)
    assert serialized == {"a": 1, "b": "b", "d": 2}

(Out of curiosity; why is it not enabled by default for all?)

I find folks usually use init=False attributes for computed attributes that get set in __attrs_post_init__.

point me to docs if there’s any for this

I guess we don't have exact docs for this, but you can look at https://catt.rs/en/latest/usage.html#using-factory-hooks for a larger example for customizing attrs handling using factory hooks.