在Python中创建嵌套的数据类对象

48

我有一个数据类对象,其中包含嵌套的数据类对象。但是,当我创建主对象时,嵌套的对象会变成字典:

@dataclass
class One:
    f_one: int
    f_two: str
    
@dataclass
class Two:
    f_three: str
    f_four: One


Two(**{'f_three': 'three', 'f_four': {'f_one': 1, 'f_two': 'two'}})

Two(f_three='three', f_four={'f_one': 1, 'f_two': 'two'})

obj = {'f_three': 'three', 'f_four': One(**{'f_one': 1, 'f_two': 'two'})}

Two(**obj)
Two(f_three='three', f_four=One(f_one=1, f_two='two'))

正如您所看到的,只有 **obj 起作用。

理想情况下,我希望构建我的对象以获得类似于以下内容:

Two(f_three='three', f_four=One(f_one=1, f_two='two'))

除了手动将嵌套的字典转换为相应的数据类对象以访问对象属性外,还有其他实现方法吗?


6
如果你实际使用 obj,你的第二种方法是有效的。Two(**obj) 将给出 Two(f_three='three', f_four=One(f_one=1, f_two='two')) - Patrick Haugh
谢谢指出我的错误。不知道是否可能使用第一种方法实现相同的结果?如果您的数据类对象中有多个嵌套对象,则第二种方法似乎过于繁琐。 - mohi666
可能是Python dataclass from dict的重复问题。 - Arne
3
今天似乎可以运行(Python 3.7和3.9): a = Two(f_three='three', f_four=One(f_one=1, f_two='two')); print(a) - timotheecour
你可以查看 chili 库,它简化了许多数据类的序列化和反序列化。它还有一些不错的附加功能,如映射和字段隐藏:https://github.com/kodemore/chili - Krac
12个回答

43
您可以使用 post_init 来实现此功能。
from dataclasses import dataclass
@dataclass
class One:
    f_one: int
    f_two: str

@dataclass
class Two:
    f_three: str
    f_four: One
    def __post_init__(self):
        self.f_four = One(**self.f_four)

data = {'f_three': 'three', 'f_four': {'f_one': 1, 'f_two': 'two'}}

print(Two(**data))
# Two(f_three='three', f_four=One(f_one=1, f_two='two'))

好的,简单的,没有外部依赖。 - Davos
4
好的回答,但是f_four希望得到一个字典而不是One类的实例。 我们可以使用Union类型,像这样[One, dict]。 - Bastien B
太棒了!正是我所需要的,谢谢。 - Dawngerpony
1
这就是我在寻找的答案。如果可能的话,对于一些相当琐碎的事情,最好不要使用外部库。 - vancouverwill

37

这是一个和dataclasses模块一样复杂的请求,这意味着实现“嵌套字段”功能的最佳方式可能是定义一个新的装饰器,类似于@dataclass

幸运的是,如果您不需要像调用dataclass生成的类那样反映字段和默认值的__init__方法的签名,那么这可以简单得多:一个类装饰器将调用原来的dataclass,并在其生成的__init__方法上包装一些功能,就可以使用纯文本"...(*args,**kwargs):"风格的函数完成。

换句话说,只需编写一个包装器,围绕生成的__init__方法,该方法将检查传递给“kwargs”的参数,检查是否对应于“dataclass字段类型”,如果是,则在调用原始 __init__ 之前生成嵌套对象。也许这在英语中比在Python中更难拼写:

from dataclasses import dataclass, is_dataclass

def nested_dataclass(*args, **kwargs):
    def wrapper(cls):
        cls = dataclass(cls, **kwargs)
        original_init = cls.__init__
        def __init__(self, *args, **kwargs):
            for name, value in kwargs.items():
                field_type = cls.__annotations__.get(name, None)
                if is_dataclass(field_type) and isinstance(value, dict):
                     new_obj = field_type(**value)
                     kwargs[name] = new_obj
            original_init(self, *args, **kwargs)
        cls.__init__ = __init__
        return cls
    return wrapper(args[0]) if args else wrapper
注意,除了不用担心__init__的签名之外,这还忽略了传递init=False——因为它无论如何都没有意义。 (返回行中的if负责使其可以作为具有命名参数或直接作为装饰器调用,就像dataclass本身一样) 在交互式提示符上:
In [85]: @dataclass
    ...: class A:
    ...:     b: int = 0
    ...:     c: str = ""
    ...:         

In [86]: @dataclass
    ...: class A:
    ...:     one: int = 0
    ...:     two: str = ""
    ...:     
    ...:         

In [87]: @nested_dataclass
    ...: class B:
    ...:     three: A
    ...:     four: str
    ...:     

In [88]: @nested_dataclass
    ...: class C:
    ...:     five: B
    ...:     six: str
    ...:     
    ...:     

In [89]: obj = C(five={"three":{"one": 23, "two":"narf"}, "four": "zort"}, six="fnord")

In [90]: obj.five.three.two
Out[90]: 'narf'

如果你想保留签名,我建议使用dataclasses模块中的私有辅助函数来创建一个新的__init__


你已经使用装饰器链接了属性。太棒了! - pylang
的确 - 我认为这段代码片段可能值得拥有自己的Pypi模块。我看到它已经发布了。 - jsbueno
4
记录一下,对于类型为List[dataclass]的字段,dataclasses.is_dataclass(f.type)会返回false,因此你的装饰器会跳过这样的字段。详情请参见https://dev59.com/K1QJ5IYBdhLWcg3wqXt6?noredirect=1#comment93683831_53376099。 - mbatchkarov
6
更新:需要此功能的用户,请查看“pydantic”库 - 我认为它可以处理这个问题,只需提供足够的代码以应对边缘情况。 - jsbueno
指向@mbatchkarov评论。我正在寻找完全相同的东西。有人吗? - M.wol
显示剩余3条评论

22
你可以尝试使用 dacite 模块。该包简化了从字典创建数据类的过程,同时还支持嵌套结构。
示例:
from dataclasses import dataclass
from dacite import from_dict

@dataclass
class A:
    x: str
    y: int

@dataclass
class B:
    a: A

data = {
    'a': {
        'x': 'test',
        'y': 1,
    }
}

result = from_dict(data_class=B, data=data)

assert result == B(a=A(x='test', y=1))

要安装dacite,只需使用pip:

$ pip install dacite

13

我没有编写新的装饰器,而是想出了一种在实际数据类初始化后修改所有数据类类型字段的函数。

def dicts_to_dataclasses(instance):
    """Convert all fields of type `dataclass` into an instance of the
    specified data class if the current value is of type dict."""
    cls = type(instance)
    for f in dataclasses.fields(cls):
        if not dataclasses.is_dataclass(f.type):
            continue

        value = getattr(instance, f.name)
        if not isinstance(value, dict):
            continue

        new_value = f.type(**value)
        setattr(instance, f.name, new_value)

函数可以手动调用或在__post_init__中调用。这样@dataclass装饰器就可以完全发挥作用。
上面的示例调用了__post_init__:
@dataclass
class One:
    f_one: int
    f_two: str

@dataclass
class Two:
    def __post_init__(self):
        dicts_to_dataclasses(self)

    f_three: str
    f_four: One

data = {'f_three': 'three', 'f_four': {'f_one': 1, 'f_two': 'two'}}

two = Two(**data)
# Two(f_three='three', f_four=One(f_one=1, f_two='two'))

6

我对@jsbueno的解决方案进行了扩展,还可以接受以List[<你的类/>]形式输入。

def nested_dataclass(*args, **kwargs):
    def wrapper(cls):
        cls = dataclass(cls, **kwargs)
        original_init = cls.__init__

        def __init__(self, *args, **kwargs):
            for name, value in kwargs.items():
                field_type = cls.__annotations__.get(name, None)
                if isinstance(value, list):
                    if field_type.__origin__ == list or field_type.__origin__ == List:
                        sub_type = field_type.__args__[0]
                        if is_dataclass(sub_type):
                            items = []
                            for child in value:
                                if isinstance(child, dict):
                                    items.append(sub_type(**child))
                            kwargs[name] = items
                if is_dataclass(field_type) and isinstance(value, dict):
                    new_obj = field_type(**value)
                    kwargs[name] = new_obj
            original_init(self, *args, **kwargs)

        cls.__init__ = __init__
        return cls

    return wrapper(args[0]) if args else wrapper

1
使用您的装饰器,如果数据类属性中有注释为List[SomeClass],则会出现AttributeError: type object 'list' has no attribute 'origin'。 - M.wol

2
非常重要的问题并不是嵌套,而是值的验证/转换。您需要对值进行验证吗?
如果需要值验证,请使用经过充分测试的反序列化库,例如:
- pydantic(更快,但混乱的保留属性如"schema"会干扰来自数据的属性名称。必须重命名和别名类属性以使其烦人) - schematics(比pydantic慢,但拥有更成熟的类型转换堆栈)
它们具有出色的验证和重新转换支持,并广泛使用(意味着通常可以很好地工作,并且不会弄乱您的数据)。但是,它们不是基于dataclass的,尽管Pydantic包装了dataclass功能,并允许您通过更改导入语句从纯数据类切换到受Pydantic支持的数据类。
这些库(在本主题中提到)与dataclasses本地集成,但验证/类型转换尚未加强。
- dacite - validated_dc
如果验证不是非常重要,只需要递归嵌套,那么像https://gist.github.com/dvdotsenko/07deeafb27847851631bfe4b4ddd9059这样的简单手写代码就足以处理OptionalList[Dict[nested models]]

2
如果您愿意将此功能与非标准库 attrs 库(提供 dataclass stdlib 功能的超集)配对使用,那么 cattrs 库提供了一个结构函数,它可以处理本地数据类型转换为数据类,并自动使用类型注释。请点击链接the cattrs library查看更多信息。

1

你的示例在最近的Python版本中按预期工作。

然而,文档 对于嵌套dataclasses仍然完全缺失。如果有默认参数,则以下方法也适用:

from dataclasses import dataclass

@dataclass
class One:
    f_one: int = 1
    f_two: str = 'two'
    
@dataclass
class Two:
    f_three: str = 'three'
    f_four: One = One()

# nested class instance with default parameters
example = Two()
example

# nested class instance with different parameters
example = Two(f_three='four', f_four=One(f_one=2, f_two='three'))
example

# same but using dict unpacking
example = Two(**{'f_three': 'five', 'f_four': One(**{'f_one': 3, 'f_two': 'four'})})
example

# or, by changing the class initialization method to ingest a vanilla dict:
@dataclass
class Two:
    f_three: str = '3'
    f_four: One = One()
    def __init__(self, d: dict):
        self.f_three = d.get('f_three')
        self.f_four  = One(**d.get('f_four'))

d = {'f_three': 'six', 'f_four': {'f_one': 4, 'f_two': 'five'}}
example = Two(d)
example

重要的是,指向嵌套dataclass的类成员应该具有dataclass的类型,并使用其值进行初始化。您可以以这种方式嵌套任意级别的dataclasses
另一种方法是简单地使用dict,它很容易序列化/反序列化为JSON:
# dict is all you need
example = {
    'three': '3',
    'four': {
        'one': 1,
        'two': '2',
    }
}

一个从Kaggle借鉴来的老技巧是将嵌套的listdict解包到一个Struct中,该结构不是dataclass,以便进行点访问:
class Struct(dict):
    """Dataclass structure that inherits from dict."""
    def __init__(self, **entries):
        entries = {k: v for k, v in entries.items() if k != 'items'}
        dict.__init__(self, entries)
        self.__dict__.update(entries)
    def __setattr__(self, attr, value):
        self.__dict__[attr] = value
        self[attr] = value

def structify(obj: Union[list,dict]) -> Struct:
    """Unpack list or dict into Struct for dot access of members."""
    if isinstance(obj, list):
        return [structify(obj[i]) for i in range(len(obj))]
    elif isinstance(obj, dict):
        return Struct(**{k: structify(v) for k, v in obj.items()})
    return obj  # else return input object

s = structify(example)
s
s.three
s.four.one
s.four.two

你也可以创建一个TypedDict,但为什么要将字典和类的最差方面结合起来呢?对于每种其他语言都提供的基本功能,不应该需要外部库。你期望嵌套的数据类的行为类似于嵌套的C/C++结构体,但实际上它们非常不同。否则,pydantic 从解包的字典生成类型化类的界面很好。总的来说,Julia 在 @kwdef 宏中处理参数数据结构的方法更好:

@kwdef struct Foo
    a::Int = 1     # default value
    b::String      # required keyword
end

Foo(b="hi")

1

dataclass-wizard 是一种现代选择,可以为您提供替代方案。它支持复杂类型,例如日期和时间,typing 模块中的泛型以及嵌套的 nested dataclass 结构。

其他“有用但不是必需”的功能,例如隐式键大小写转换——即常见于 API 响应的 camelCaseTitleCase ——同样在开箱即用时受到支持。

通过下面所示的 __future__ 导入,PEP 585604 中引入的“新风格”注释可以被移植回 Python 3.7。

from __future__ import annotations
from dataclasses import dataclass
from dataclass_wizard import fromdict, asdict, DumpMeta


@dataclass
class Two:
    f_three: str | None
    f_four: list[One]


@dataclass
class One:
    f_one: int
    f_two: str


data = {'f_three': 'three',
        'f_four': [{'f_one': 1, 'f_two': 'two'},
                   {'f_one': '2', 'f_two': 'something else'}]}

two = fromdict(Two, data)
print(two)

# setup key transform for serialization (default is camelCase)
DumpMeta(key_transform='SNAKE').bind_to(Two)

my_dict = asdict(two)
print(my_dict)

输出:

Two(f_three='three', f_four=[One(f_one=1, f_two='two'), One(f_one=2, f_two='something else')])
{'f_three': 'three', 'f_four': [{'f_one': 1, 'f_two': 'two'}, {'f_one': 2, 'f_two': 'something else'}]}

你可以通过 pip 安装 Dataclass Wizard:

$ pip install dataclass-wizard

1
我建立了一个库,它与另一个库非常相似,但有一个例外,在我的看法中,这是一个致命缺陷。你不需要扩展任何东西就可以让它工作。它几乎涵盖了你可以在typing包中找到的所有内容:https://github.com/kodemore/chili - Krac

1

你也可以使用chili。这是我专门为此目的构建的库。您在代码中需要做的唯一更改就是导入以下类似的一个函数:

from chili import init_dataclass

@dataclass
class One:
    f_one: int
    f_two: str
    
@dataclass
class Two:
    f_three: str
    f_four: One


two = init_dataclass({'f_three': 'three', 'f_four': {'f_one': 1, 'f_two': 'two'}}, Two)

安装非常简单:

pip install chili

或者

poetry add chili

您可以在这里了解更多信息:https://github.com/kodemore/chili


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接