python-attrs / cattrs

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

Covert to defaultdict instead of plain dict #519

Closed 4l1fe closed 3 months ago

4l1fe commented 4 months ago

Description

I expect the method SelectorConfig.load() to convert the toml text to the defaultdict type in SelectorConfig.properties. Instead, it is converted as dict

How to configure structurizing to make it recognizing the exact type which is DefaultDict[str, LineStringProperties] ?

What I Did

config_converter = TomlkitConverter()

@attrs.define
class LineStringProperties:
    pinned: bool = False
    comment: str = ''
    theme_group: str = LIGHT_THEME

@attrs.define
class SelectorConfig:
    properties: DefaultDict[str, LineStringProperties] = attrs.field(factory=lambda: defaultdict(LineStringProperties))

   @staticmethod
    def load(config_path: Path) -> 'SelectorConfig':
        if not config_path.exists():
            return SelectorConfig()

        text = config_path.read_text()
        config = config_converter.loads(text, SelectorConfig)
        return config
Tinche commented 3 months ago

Hi,

that's an interesting question. I think nobody's asked for it over the years (or I've forgotten about it); we should probably support it since it's a standard library thing.

First you need a predicate function to see if a type is a defaultdict. Note that on 3.9+ (you're on 3.11) you don't need to use typing.DefaultDict, you can just use collections.defaultdict in type hints.

Something like this:

from collections import defaultdict
from typing import DefaultDict, get_origin

def is_defaultdict(type: Any) -> bool:
    """Is this type a defaultdict?"""
    return is_subclass(get_origin(type), (defaultdict, DefaultDict))

Now you need a structure hook factory. We can wrap an existing hook factory, cattrs.gen.make_mapping_structure_fn. You'll need the c Converter to be in scope.

from functools import partial
from typing import get_args

from cattrs.gen import make_mapping_structure_fn
from cattrs.dispatch import StructureHook

def structure_defaultdict_factory(type: type[defaultdict]) -> StructureHook:
    value_type = get_args(type)[1]
    return make_mapping_structure_fn(type, c, partial(defaultdict, value_type))

And then you tie to all together:

c = TomlkitConverter()
c.register_structure_hook_factory(is_defaultdict, structure_defaultdict_factory)

Now you can structure defaultdicts. Since defaultdicts take an arbitrary lambda when created it won't work for all defaultdicts, but should for most.

I'll look into adding this to the next version.

4l1fe commented 3 months ago

Thank you

Hi,

that's an interesting question. I think nobody's asked for it over the years (or I've forgotten about it); we should probably support it since it's a standard library thing.

First you need a predicate function to see if a type is a defaultdict. Note that on 3.9+ (you're on 3.11) you don't need to use typing.DefaultDict, you can just use collections.defaultdict in type hints.

Hey, thx, i didn't know it.

The problem

Something like this:

from collections import defaultdict
from typing import DefaultDict, get_origin

def is_defaultdict(type: Any) -> bool:
    """Is this type a defaultdict?"""
    return is_subclass(get_origin(type), (defaultdict, DefaultDict))

Now you need a structure hook factory. We can wrap an existing hook factory, cattrs.gen.make_mapping_structure_fn. You'll need the c Converter to be in scope.

from functools import partial
from typing import get_args

from cattrs.gen import make_mapping_structure_fn
from cattrs.dispatch import StructureHook

def structure_defaultdict_factory(type: type[defaultdict]) -> StructureHook:
    value_type = get_args(type)[1]
    return make_mapping_structure_fn(type, c, partial(defaultdict, value_type))

And then you tie to all together:

c = TomlkitConverter()
c.register_structure_hook_factory(is_defaultdict, structure_defaultdict_factory)

Now you can structure defaultdicts. Since defaultdicts take an arbitrary lambda when created it won't work for all defaultdicts, but should for most.

In short, it works, thx for the working solution, but here what i'm thinking about it:

The quesiton

I'll look into adding this to the next version.

You mean the defaultdict case will be covered without the hook factory magic?

Tinche commented 3 months ago

You mean the defaultdict case will be covered without the hook factory magic?

Yeah, simple cases we can handle automatically. I'm working on a PR already.

I can look at your other comments later, really busy with work ATM.

4l1fe commented 3 months ago

You mean the defaultdict case will be covered without the hook factory magic?

Yeah, simple cases we can handle automatically. I'm working on a PR already.

I can look at your other comments later, really busy with work ATM.

No worries