python-attrs / cattrs

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

include_subclasses doesn't seem to work on members of a class #470

Closed scottee closed 9 months ago

scottee commented 9 months ago

Description

I'm trying to have attrs of a class be typed to a base class, and have the config structure them into child classes. However, the attrs always come out as the base class. This seems straightforward. Am I doing something wrong?

What I Did

Below is the python I used:

# Content of test_cattrs.py
from attrs import define, field, frozen
from cattrs.strategies import include_subclasses
from cattrs import Converter
from typing import Dict, List

@define
class Base:
    pass

@define
class Child1(Base):
    a: str
    b: str
    c: List[str] = field(factory=list)

@define
class Child2(Base):
    d: str

@define
class TestConfig:
    # If you switch to a dict, the same problem happens.
    #bases: Dict[str, Base]
    # If using a dict, comment out these attrs.
    c1: Base
    c2: Base
    c3: Base

conf = {
    #'bases': {
        'c1': {
            'a': 'foo',
            'b': 'blah'
        },
        'c2': {
            'c': ['c1'],
            'a': 'yada',
            'b': 'wonka'
        },
        'c3': {
            'd': 'pkg.module.func'
        },
    #}
}

converter = Converter()
include_subclasses(TestConfig, converter)
# The c* attrs come out as Base class, not one of the child classes
print(converter.structure(conf, TestConfig))

If you run this cattrs parses to the base class:

$ python test_cattrs.py
TestConfig(c1=Base(), c2=Base(), c3=Base())
Tinche commented 9 months ago

Howdy!

You're on the right track but you're applying the strategy to the wrong class. :)

If you change it to this instead:

include_subclasses(Base, converter)

the output changes to:

TestConfig(c1=Child1(a='foo', b='blah', c=[]), c2=Child1(a='yada', b='wonka', c=['c1']), c3=Child2(d='pkg.module.func'))

What you're actually doing by applying the strategy is making the rules for Base be equivalent to the rules for Union[Child2, Child1, Base]. The fact that TestConfig is using Base is coincidental. For example: this also works now:

converter.structure([{"a": 1, "b": 1}], list[Base])
# [Child1(a='1', b='1', c=[])]

Hope that helps!