当 ruamel.yaml 从字符串加载 @dataclass 时,不会调用 __post_init__

Posted

技术标签:

【中文标题】当 ruamel.yaml 从字符串加载 @dataclass 时,不会调用 __post_init__【英文标题】:When ruamel.yaml loads @dataclass from string, __post_init__ is not called 【发布时间】:2019-01-02 21:30:44 【问题描述】:

假设我创建了一个@dataclass class Foo,并添加了一个__post_init__ 来执行类型检查和处理。

当我尝试yaml.load !Foo 对象时,__post_init__ 不会被调用。

from dataclasses import dataclass, fields

from ruamel.yaml import yaml_object, YAML


yaml = YAML()


@yaml_object(yaml)
@dataclass
class Foo:
    foo: int
    bar: int

    def __post_init__(self):
        raise Exception
        for field in fields(self):
            value = getattr(self, field.name)
            typ = field.type
            if not isinstance(value, typ):
                raise Exception

s = '''\
!Foo
foo: "foo"
bar: "bar"
'''
yaml.load(s)

通过 ruamel.yaml 加载数据类时如何进行参数检查?

此行为发生在 Python 3.7 以及带有pip install dataclasses 的 3.6 中。

【问题讨论】:

自从我学习了 Rust,我就开始编写静态工厂方法而不是构造函数。也许如果我再次遇到这个问题,我会以不同的方式处理它。不确定。 【参考方案1】:

之所以没有调用__post_init__,是因为ruamel.yaml(以及其Constructors 中的PyYAML 代码)早在dataclasses 创建之前就已创建。

当然,可以将调用__post_init_() 的代码添加到ruamel.yaml 的Python 对象构造函数中,最好在测试是否使用@dataclass 创建某些东西之后,否则为非Data-Class 类,即恰好有这样一个名为__post_init_ 的方法,在加载过程中会突然调用该方法。

如果你没有这样的类,你可以在第一次加载/转储之前使用yaml.Constructor = MyConstructorYAML()实例添加你自己的更智能的构造函数(此时构造函数被实例化)。但是添加一个构造函数并不像继承RoundTripConstructor那么简单,因为所有支持的节点类型都需要在这样一个新的构造函数类型上注册。

大多数时候,我发现在RoundTripConstructor 上修补适当的方法会更容易:

from dataclasses import dataclass, fields
from ruamel.yaml import yaml_object, YAML, RoundTripConstructor


def my_construct_yaml_object(self, node, cls):
    for data in self.org_construct_yaml_object(node, cls):
      yield data
    # not doing a try-except, in case `__post_init__` does catch the AttributeError
    post_init = getattr(data, '__post_init__', None)
    if post_init:
        post_init()

RoundTripConstructor.org_construct_yaml_object = RoundTripConstructor.construct_yaml_object
RoundTripConstructor.construct_yaml_object = my_construct_yaml_object

yaml = YAML()
yaml.preserve_quotes = True

@yaml_object(yaml)
@dataclass
class Foo:
    foo: int
    bar: int

    def __post_init__(self):
        for field in fields(self):
            value = getattr(self, field.name)
            typ = field.type
            if not isinstance(value, typ):
                raise Exception

s = '''\
!Foo
foo: "foo"
bar: "bar"
'''
d = yaml.load(s)

抛出异常:

Traceback (most recent call last):
  File "try.py", line 36, in <module>
    d = yaml.load(s)
  File "/home/venv/tmp-46489abf428c4cd4/lib/python3.7/site-packages/ruamel/yaml/main.py", line 266, in load
    return constructor.get_single_data()
  File "/home/venv/tmp-46489abf428c4cd4/lib/python3.7/site-packages/ruamel/yaml/constructor.py", line 105, in get_single_data
    return self.construct_document(node)
  File "/home/venv/tmp-46489abf428c4cd4/lib/python3.7/site-packages/ruamel/yaml/constructor.py", line 115, in construct_document
    for dummy in generator:
  File "try.py", line 10, in my_construct_yaml_object
    post_init()
  File "try.py", line 29, in __post_init__
    raise Exception
Exception

请注意,YAML 中的双引号是多余的,因此如果您想在往返时保留这些双引号,则需要 yaml.preserve_quotes = True

【讨论】:

【参考方案2】:

我不完全确定这是否是正确的解决方法...

我可以将逻辑从__post_init__ 移动到__setstate__(state: dict),由YAML().load() 调用。

def __setstate__(self, state):
    self.__dict__.update(state)
    # I could call self.__post_init__(), or alternatively move logic here:
    for field in fields(self):
        value = getattr(self, field.name)
        typ = field.type
        if not isinstance(value, typ):
            raise Exception

YAML().load(s) 调用 Foo.__setstate__(state) 如果该方法存在,但显然不是 __init__(它调用 __post_init__)。这是一个有意的设计决定吗?

【讨论】:

是的,这是一个有意的设计决定,它类似于 pickle 所做的。如果你pickle.dump 你的Foo 实例(没有__post_init__)然后尝试pickle.loadwith __post_init__,你会注意到它也没有被调用。另见my answer here

以上是关于当 ruamel.yaml 从字符串加载 @dataclass 时,不会调用 __post_init__的主要内容,如果未能解决你的问题,请参考以下文章

防止长行被包裹在 ruamel.yaml

通过 ruamel.yaml 转储时如何在 yaml 文件中保留空值

python ruamel.yaml 包,如何获取标题注释行?

Python之ruamel.yaml模块详解| ruamel.yaml与pyyaml的区别

Python之ruamel.yaml模块详解

ruamel.yaml的使用