来自嵌套字典的 Python 数据类

Posted

技术标签:

【中文标题】来自嵌套字典的 Python 数据类【英文标题】:Python dataclass from a nested dict 【发布时间】:2019-04-21 21:35:13 【问题描述】:

3.7 中的标准库可以递归地将数据类转换为字典(来自文档的示例):

from dataclasses import dataclass, asdict
from typing import List

@dataclass
class Point:
     x: int
     y: int

@dataclass
class C:
     mylist: List[Point]

p = Point(10, 20)
assert asdict(p) == 'x': 10, 'y': 20

c = C([Point(0, 0), Point(10, 4)])
tmp = 'mylist': ['x': 0, 'y': 0, 'x': 10, 'y': 4]
assert asdict(c) == tmp

我正在寻找一种在嵌套时将字典转回数据类的方法。像C(**tmp) 这样的东西只有在数据类的字段是简单类型而不是它们本身的数据类时才有效。我熟悉 [jsonpickle][1],但它带有一个突出的安全警告。


编辑:

答案建议了以下库:

英安 mashumaro(我用了一段时间,效果不错,但很快就遇到了棘手的角落案例) pydantic(工作得非常好,优秀的文档和更少的极端案例) [1]:https://jsonpickle.github.io/

【问题讨论】:

这个被标记为重复的问题确实是在问同样的问题,但是那里给出的答案不适用于这个特定的例子。我在那里留下了评论,仍在寻找更一般的答案。 您能在这里明确说明这一点吗?看起来您可能必须在 if 中添加一个 elif 以检查各种提示。我不确定您如何将其推广到任意类型提示(例如,DictTuple 以及 List asdict 正在丢失信息。在一般情况下是不可能做到这一点的。 具体来说,asdict 不存储有关 dict 是从哪个类生成的任何信息。给定class A: x: intclass B: x: int,应该使用'x': 5 来创建AB 的实例吗?您似乎在假设属性名称列表唯一地定义了一个列表,并且存在名称到数据类的现有映射,可用于选择正确的类。 我建议你看看这个library。 【参考方案1】:

我知道现在实际上可能有大量的 JSON 序列化库,老实说,我可能有点晚才发现这篇文章。但是,dataclass-wizard 库也提供了一个更新(且经过充分测试)的选项。从 v0.18.0 版本开始,这最近(无论如何都是两周前)移至 Production/Stable 状态。

它对从typing 模块输入泛型以及其他利基用例(例如Union 类型中的数据类和带模式的日期和时间)提供了相当可靠的支持。我个人认为非常有用的其他不错的功能,例如自动 key case 转换(即骆驼到蛇)和隐式 type 强制转换(即字符串到注释int) 也已实现。

理想的用法是JSONWizard Mixin 类,它提供了有用的类方法,例如:

from_json from_dict / from_list to_dict to_json / list_to_json

这是一个非常不言自明的用法,已经在 Python 3.7+ 中进行了测试,其中包含 __future__ 导入:

from __future__ import annotations

from dataclasses import dataclass
from dataclass_wizard import JSONWizard


@dataclass
class C(JSONWizard):
    my_list: list[Point]


@dataclass
class Point(JSONWizard):
    x: int
    y: int


# Serialize Point instance
p = Point(10, 20)
tmp = 'x': 10, 'y': 20
assert p.to_dict() == tmp
assert Point.from_dict(tmp) == p

c = C([Point(0, 0), Point(10, 4)])
# default case transform is 'camelCase', though this can be overridden
# with a custom Meta config supplied for the main dataclass.
tmp = 'myList': ['x': 0, 'y': 0, 'x': 10, 'y': 4]
assert c.to_dict() == tmp
assert C.from_dict(tmp) == c

NB:值得注意的是,从技术上讲,您只需要对主要数据类进行子类化,即正在序列化的模型;如果需要,可以单独保留嵌套的数据类。

如果完全不需要类继承模型,另一种选择是使用导出的辅助函数(例如fromdictasdict)根据需要将数据类实例与 Python dict 对象相互转换。

【讨论】:

【参考方案2】:

我真的认为gatopeich 在this 答案中提出的概念是解决这个问题的最佳方法。

我已经修正并文明化了他的代码。这是从字典中加载数据类的正确函数:

def dataclass_from_dict(cls: type, src: t.Mapping[str, t.Any]) -> t.Any:
    field_types_lookup = 
        field.name: field.type
        for field in dataclasses.fields(cls)
    

    constructor_inputs = 
    for field_name, value in src.items():
        try:
            constructor_inputs[field_name] = dataclass_from_dict(field_types_lookup[field_name], value)
        except TypeError as e:
            # type error from fields() call in recursive call
            # indicates that field is not a dataclass, this is how we are
            # breaking the recursion. If not a dataclass - no need for loading
            constructor_inputs[field_name] = value
        except KeyError:
            # similar, field not defined on dataclass, pass as plain field value
            constructor_inputs[field_name] = value

return cls(**constructor_inputs)

然后您可以使用以下方法进行测试:

@dataclass
class Point:
    x: float
    y: float


@dataclass
class Line:
    a: Point
    b: Point

p1, p2 = Point(1,1), Point(2,2)
line = Line(p1, p1)

assert line == dataclass_from_dict(Line, asdict(line))

【讨论】:

【参考方案3】:

我还没有看到提到的一个可能的解决方案是使用dataclasses-json。该库提供 dataclass 实例与 JSON 之间的转换,还提供与 dict 之间的转换(如 dacitemashumaro,在之前的答案中提出了建议)。

dataclasses-json 需要使用@dataclass_json@dataclass 来装饰类。然后,装饰类获得几个成员函数,用于与 JSON 之间的转换以及与dict 之间的转换:

from_dict(...) from_json(...) to_dict(...) to_json(...)

这是问题中原始代码的略微修改版本。我添加了所需的@dataclass_json 装饰器和asserts,用于从dicts 转换为PointC 的实例:

from dataclasses import dataclass, asdict
from dataclasses_json import dataclass_json
from typing import List

@dataclass_json
@dataclass
class Point:
     x: int
     y: int

@dataclass_json
@dataclass
class C:
     mylist: List[Point]

p = Point(10, 20)

assert asdict(p) == 'x': 10, 'y': 20
assert p == Point.from_dict('x': 10, 'y': 20)

c = C([Point(0, 0), Point(10, 4)])

tmp = 'mylist': ['x': 0, 'y': 0, 'x': 10, 'y': 4]

assert asdict(c) == tmp
assert c == C.from_dict(tmp)

【讨论】:

对于那些寻求最简单实现的人来说可能是最简单的实现。【参考方案4】:

也支持列表的简单解决方案(并且可以扩展为其他通用用途)

from dataclasses import dataclass, asdict, fields, is_dataclass
from typing import List
from types import GenericAlias

def asdataclass(klass, d):
    if not is_dataclass(klass):
        return d
    values = 
    for f in fields(klass):
        if isinstance(f.type, GenericAlias) and f.type.__origin__ == list:
            values[f.name] = [asdataclass(f.type.__args__[0], d2) for d2 in d[f.name]]
        else:
            values[f.name] = asdataclass(f.type,d[f.name])
    return klass(**values)

@dataclass
class Point:
    x: int
    y: int

@dataclass
class C:
    mylist: list[Point]
    title: str = ""

c = C([Point(0, 0), Point(10, 4)])

assert c == asdataclass(C, asdict(c))

基于https://***.com/a/54769644/871166

【讨论】:

【参考方案5】:
from validated_dc import ValidatedDC
from dataclasses import dataclass

from typing import List, Union


@dataclass
class Foo(ValidatedDC):
    foo: int


@dataclass
class Bar(ValidatedDC):
    bar: Union[Foo, List[Foo]]


foo = 'foo': 1
instance = Bar(bar=foo)
print(instance.get_errors())  # None
print(instance)               # Bar(bar=Foo(foo=1))

list_foo = ['foo': 1, 'foo': 2]
instance = Bar(bar=list_foo)
print(instance.get_errors())  # None
print(instance)               # Bar(bar=[Foo(foo=1), Foo(foo=2)])

validated_dc:https://github.com/EvgeniyBurdin/validated_dc

并查看更详细的示例:https://github.com/EvgeniyBurdin/validated_dc/blob/master/examples/detailed.py

【讨论】:

【参考方案6】:

不使用其他模块,您可以使用__post_init__ 函数将dict 值自动转换为正确的类型。这个函数在__init__之后调用。

from dataclasses import dataclass, asdict


@dataclass
class Bar:
    fee: str
    far: str

@dataclass
class Foo:
    bar: Bar

    def __post_init__(self):
        if isinstance(self.bar, dict):
            self.bar = Bar(**self.bar)

foo = Foo(bar=Bar(fee="La", far="So"))

d= asdict(foo)
print(d)  # 'bar': 'fee': 'La', 'far': 'So'
o = Foo(**d)
print(o)  # Foo(bar=Bar(fee='La', far='So'))

此解决方案的另一个好处是能够使用非数据类对象。只要它的str 函数可以转换回来,这是公平的游戏。例如,它可用于在内部将str 字段保留为IP4Address

【讨论】:

这是最简单的解决方案,不依赖自定义函数或外部库,因此更便于移植。它也适用于集合以及使用集合嵌套多个类。应标记为正确答案。【参考方案7】:

我想建议使用复合模式来解决这个问题, 主要优点是您可以继续向此模式添加类 并让它们的行为方式相同。

from dataclasses import dataclass
from typing import List


@dataclass
class CompositeDict:
    def as_dict(self):
        retval = dict()
        for key, value in self.__dict__.items():
            if key in self.__dataclass_fields__.keys():
                if type(value) is list:
                    retval[key] = [item.as_dict() for item in value]
                else:
                    retval[key] = value
        return retval

@dataclass
class Point(CompositeDict):
    x: int
    y: int


@dataclass
class C(CompositeDict):
    mylist: List[Point]


c = C([Point(0, 0), Point(10, 4)])
tmp = 'mylist': ['x': 0, 'y': 0, 'x': 10, 'y': 4]
assert c.as_dict() == tmp

作为旁注,您可以在 CompositeDict 类中使用工厂模式来处理嵌套字典、元组等其他情况,这将节省大量样板文件。

【讨论】:

这个解决方案很差,太复杂了,你应该使用像“dacite”这样的外部库。 @jurass 如果 OP 想使用库,他不会问这个问题【参考方案8】:

Validobj 就是这样做的。与其他库相比,它提供了更简单的界面(目前只有一个功能)并强调信息丰富的错误消息。例如,给定一个类似

的模式
import dataclasses
from typing import Optional, List


@dataclasses.dataclass
class User:
    name: str
    phone: Optional[str] = None
    tasks: List[str] = dataclasses.field(default_factory=list)

一个错误像

>>> import validobj
>>> validobj.parse_input(
...      'phone': '555-1337-000', 'address': 'Somewhereville', 'nme': 'Zahari', User
... )
Traceback (most recent call last):
...
WrongKeysError: Cannot process value into 'User' because fields do not match.
The following required keys are missing: 'name'. The following keys are unknown: 'nme', 'address'.
Alternatives to invalid value 'nme' include:
  - name

All valid options are:
  - name
  - phone
  - tasks

对于给定字段的拼写错误。

【讨论】:

【参考方案9】:

undictify 是一个可以提供帮助的库。这是一个最小的用法示例:

import json
from dataclasses import dataclass
from typing import List, NamedTuple, Optional, Any

from undictify import type_checked_constructor


@type_checked_constructor(skip=True)
@dataclass
class Heart:
    weight_in_kg: float
    pulse_at_rest: int


@type_checked_constructor(skip=True)
@dataclass
class Human:
    id: int
    name: str
    nick: Optional[str]
    heart: Heart
    friend_ids: List[int]


tobias_dict = json.loads('''
    
        "id": 1,
        "name": "Tobias",
        "heart": 
            "weight_in_kg": 0.31,
            "pulse_at_rest": 52
        ,
        "friend_ids": [2, 3, 4, 5]
    ''')

tobias = Human(**tobias_dict)

【讨论】:

【参考方案10】:

只需要一个五线:

def dataclass_from_dict(klass, d):
    try:
        fieldtypes = f.name:f.type for f in dataclasses.fields(klass)
        return klass(**f:dataclass_from_dict(fieldtypes[f],d[f]) for f in d)
    except:
        return d # Not a dataclass field

示例用法:

from dataclasses import dataclass, asdict

@dataclass
class Point:
    x: float
    y: float

@dataclass
class Line:
    a: Point
    b: Point

line = Line(Point(1,2), Point(3,4))
assert line == dataclass_from_dict(Line, asdict(line))

完整的代码,包括到/从 json,这里是 gist:https://gist.github.com/gatopeich/1efd3e1e4269e1e98fae9983bb914f22

【讨论】:

这应该是公认的答案。五行代码,没有外部依赖。 +1 好的,对此进行了深入测试,现在我知道为什么它不是了。这段代码有问题。尽管如此,它是这个特定问题的最佳概念,并且调试效果非常好。 这样做的一个缺点是您丢失了返回类型提示。【参考方案11】:

我是dacite 的作者 - 该工具可简化从字典中创建数据类的过程。

这个库只有一个函数from_dict - 这是一个简单的用法示例:

from dataclasses import dataclass
from dacite import from_dict

@dataclass
class User:
    name: str
    age: int
    is_active: bool

data = 
    'name': 'john',
    'age': 30,
    'is_active': True,


user = from_dict(data_class=User, data=data)

assert user == User(name='john', age=30, is_active=True)

另外dacite支持以下功能:

嵌套结构 (基本)类型检查 可选字段(即打字。可选) 工会 收藏 值转换和转换 重新映射字段名称

...而且它已经过很好的测试 - 100% 的代码覆盖率!

要安装 dacite,只需使用 pip(或 pipenv):

$ pip install dacite

【讨论】:

太棒了!我们如何建议将此功能添加到 python 标准库中? :-) 我不明白为什么 Python 会带来 Dataclasses 但没有增加从字典(包括嵌套类)创建它们的可能性。 这是金子! :-) 不幸的是,它也很慢。虽然验证是一个很好的功能。 @rv.kvetch - 你可以试试这个分支github.com/konradhalas/dacite/tree/feature/… 它有多项性能改进【参考方案12】:

下面是asdict的CPython实现 – 或者具体来说,它使用的内部递归辅助函数_asdict_inner

# Source: https://github.com/python/cpython/blob/master/Lib/dataclasses.py

def _asdict_inner(obj, dict_factory):
    if _is_dataclass_instance(obj):
        result = []
        for f in fields(obj):
            value = _asdict_inner(getattr(obj, f.name), dict_factory)
            result.append((f.name, value))
        return dict_factory(result)
    elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
        # [large block of author comments]
        return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
    elif isinstance(obj, (list, tuple)):
        # [ditto]
        return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
    elif isinstance(obj, dict):
        return type(obj)((_asdict_inner(k, dict_factory),
                          _asdict_inner(v, dict_factory))
                         for k, v in obj.items())
    else:
        return copy.deepcopy(obj)

asdict 只是用一些断言调用上面的方法,默认情况下是dict_factory=dict

如何调整它以创建具有所需类型标记的输出字典,如 cmets 中所述?


1.添加类型信息

我的尝试涉及创建一个继承自 dict 的自定义返回包装器:

class TypeDict(dict):
    def __init__(self, t, *args, **kwargs):
        super(TypeDict, self).__init__(*args, **kwargs)

        if not isinstance(t, type):
            raise TypeError("t must be a type")

        self._type = t

    @property
    def type(self):
        return self._type

查看原始代码,只需要修改第一个子句即可使用此包装器,因为其他子句仅处理dataclass-es的containers

# only use dict for now; easy to add back later
def _todict_inner(obj):
    if is_dataclass_instance(obj):
        result = []
        for f in fields(obj):
            value = _todict_inner(getattr(obj, f.name))
            result.append((f.name, value))
        return TypeDict(type(obj), result)

    elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
        return type(obj)(*[_todict_inner(v) for v in obj])
    elif isinstance(obj, (list, tuple)):
        return type(obj)(_todict_inner(v) for v in obj)
    elif isinstance(obj, dict):
        return type(obj)((_todict_inner(k), _todict_inner(v))
                         for k, v in obj.items())
    else:
        return copy.deepcopy(obj)

进口:

from dataclasses import dataclass, fields, is_dataclass

# thanks to Patrick Haugh
from typing import *

# deepcopy 
import copy

使用的功能:

# copy of the internal function _is_dataclass_instance
def is_dataclass_instance(obj):
    return is_dataclass(obj) and not is_dataclass(obj.type)

# the adapted version of asdict
def todict(obj):
    if not is_dataclass_instance(obj):
         raise TypeError("todict() should be called on dataclass instances")
    return _todict_inner(obj)

使用示例数据类进行测试:

c = C([Point(0, 0), Point(10, 4)])

print(c)
cd = todict(c)

print(cd)
# 'mylist': ['x': 0, 'y': 0, 'x': 10, 'y': 4]

print(cd.type)
# <class '__main__.C'>

结果符合预期。


2。转换回dataclass

asdict 使用的递归例程可以重新用于反向过程,只需进行一些相对较小的更改:

def _fromdict_inner(obj):
    # reconstruct the dataclass using the type tag
    if is_dataclass_dict(obj):
        result = 
        for name, data in obj.items():
            result[name] = _fromdict_inner(data)
        return obj.type(**result)

    # exactly the same as before (without the tuple clause)
    elif isinstance(obj, (list, tuple)):
        return type(obj)(_fromdict_inner(v) for v in obj)
    elif isinstance(obj, dict):
        return type(obj)((_fromdict_inner(k), _fromdict_inner(v))
                         for k, v in obj.items())
    else:
        return copy.deepcopy(obj)

使用的功能:

def is_dataclass_dict(obj):
    return isinstance(obj, TypeDict)

def fromdict(obj):
    if not is_dataclass_dict(obj):
        raise TypeError("fromdict() should be called on TypeDict instances")
    return _fromdict_inner(obj)

测试:

c = C([Point(0, 0), Point(10, 4)])
cd = todict(c)
cf = fromdict(cd)

print(c)
# C(mylist=[Point(x=0, y=0), Point(x=10, y=4)])

print(cf)
# C(mylist=[Point(x=0, y=0), Point(x=10, y=4)])

又如预期的那样。

【讨论】:

TL;DR,+1 表示答案的全面性。 +0:+1 表示尝试,但 -1 因为一开始它基本上是个坏主意。 @wim 我同意 tbh - 不能将其视为理论练习(至少表明 dataclass 与现有对象类型配合得很好)。 我会接受这个,因为它是帮助未来用户理解问题核心的最全面的答案。我最终得到了更接近@Martijn 建议的东西,因为我确实想要 JSON。谢谢大家的回答【参考方案13】:

您可以使用mashumaro 根据方案从字典创建数据类对象。该库中的 Mixin 为数据类添加了方便的 from_dictto_dict 方法:

from dataclasses import dataclass
from typing import List
from mashumaro import DataClassDictMixin

@dataclass
class Point(DataClassDictMixin):
     x: int
     y: int

@dataclass
class C(DataClassDictMixin):
     mylist: List[Point]

p = Point(10, 20)
tmp = 'x': 10, 'y': 20
assert p.to_dict() == tmp
assert Point.from_dict(tmp) == p

c = C([Point(0, 0), Point(10, 4)])
tmp = 'mylist': ['x': 0, 'y': 0, 'x': 10, 'y': 4]
assert c.to_dict() == tmp
assert C.from_dict(tmp) == c

【讨论】:

哇,太棒了。如果对 msgpack 和 pyyaml 的依赖是可选的,我可以在某个时候看到它包含在标准库中。向数据类添加序列化是一件很容易的事,这可能是首先使用它们的最常见原因之一。【参考方案14】:

如果您的目标是生成 JSON现有的预定义 数据类,那么只需编写自定义编码器和解码器挂钩。不要在此处使用dataclasses.asdict(),而是在 JSON 中记录对原始数据类的(安全)引用。

jsonpickle 不安全,因为它存储对任意 Python 对象的引用并将数据传递给它们的构造函数。通过这样的引用,我可以让 jsonpickle 引用内部 Python 数据结构,并随意创建和执行函数、类和模块。但这并不意味着您不能不安全地处理此类引用。只需验证您只导入(而不是调用),然后验证该对象是实际的数据类类型,然后再使用它。

该框架可以足够通用,但仍仅限于 JSON 可序列化类型加上基于dataclass 的实例

import dataclasses
import importlib
import sys

def dataclass_object_dump(ob):
    datacls = type(ob)
    if not dataclasses.is_dataclass(datacls):
        raise TypeError(f"Expected dataclass instance, got 'datacls!r' object")
    mod = sys.modules.get(datacls.__module__)
    if mod is None or not hasattr(mod, datacls.__qualname__):
        raise ValueError(f"Can't resolve 'datacls!r' reference")
    ref = f"datacls.__module__.datacls.__qualname__"
    fields = (f.name for f in dataclasses.fields(ob))
    return **f: getattr(ob, f) for f in fields, '__dataclass__': ref

def dataclass_object_load(d):
    ref = d.pop('__dataclass__', None)
    if ref is None:
        return d
    try:
        modname, hasdot, qualname = ref.rpartition('.')
        module = importlib.import_module(modname)
        datacls = getattr(module, qualname)
        if not dataclasses.is_dataclass(datacls) or not isinstance(datacls, type):
            raise ValueError
        return datacls(**d)
    except (ModuleNotFoundError, ValueError, AttributeError, TypeError):
        raise ValueError(f"Invalid dataclass reference ref!r") from None

这使用JSON-RPC-style class hints 来命名数据类,并在加载时验证它仍然是具有相同字段的数据类。没有对字段的值进行类型检查(因为那是完全不同的鱼锅)。

将它们用作json.dump[s]()json.dump[s]()defaultobject_hook 参数:

>>> print(json.dumps(c, default=dataclass_object_dump, indent=4))

    "mylist": [
        
            "x": 0,
            "y": 0,
            "__dataclass__": "__main__.Point"
        ,
        
            "x": 10,
            "y": 4,
            "__dataclass__": "__main__.Point"
        
    ],
    "__dataclass__": "__main__.C"

>>> json.loads(json.dumps(c, default=dataclass_object_dump), object_hook=dataclass_object_load)
C(mylist=[Point(x=0, y=0), Point(x=10, y=4)])
>>> json.loads(json.dumps(c, default=dataclass_object_dump), object_hook=dataclass_object_load) == c
True

或使用相同的钩子创建 JSONEncoderJSONDecoder 类的实例。

您也可以使用单独的注册表来映射允许的类型名称,而不是使用完全限定的模块和类名称;在编码和解码时检查注册表,以确保您在开发时不会忘记注册数据类。

【讨论】:

以上是关于来自嵌套字典的 Python 数据类的主要内容,如果未能解决你的问题,请参考以下文章

列表中的嵌套字典到数据框python

来自数据框的嵌套字典,内部字典包含熊猫系列作为值

来自嵌套字典的多索引数据框

来自数据框的嵌套字典,带有循环列表

用 Pandas 数据框中的行填充嵌套字典

来自熊猫数据框嵌套字典的熊猫数据框