Есть ли способ построить объект, используя PyYAML construct_mapping после того, как все узлы завершат загрузку?

Я пытаюсь сделать последовательность yaml в python, которая создает пользовательский объект python. Объект должен быть сконструирован с помощью dicts и списков, которые деконструируются после __init__. Однако, кажется, что функция construct_mapping не создает целое дерево встроенных последовательностей (списков) и dicts.
Рассмотрим следующее:

import yaml

class Foo(object):
    def __init__(self, s, l=None, d=None):
        self.s = s
        self.l = l
        self.d = d

def foo_constructor(loader, node):
    values = loader.construct_mapping(node)
    s = values["s"]
    d = values["d"]
    l = values["l"]
    return Foo(s, d, l)
yaml.add_constructor(u'!Foo', foo_constructor)

f = yaml.load('''
--- !Foo
s: 1
l: [1, 2]
d: {try: this}''')

print(f)
# prints: 'Foo(1, {'try': 'this'}, [1, 2])'

Это работает отлично, потому что f содержит ссылки на объекты l и d, которые на самом деле заполнены данными после объекта Foo.

Теперь сделайте что-нибудь более сложное:

class Foo(object):
    def __init__(self, s, l=None, d=None):
        self.s = s
        # assume two-value list for l
        self.l1, self.l2 = l
        self.d = d

Теперь мы получаем следующую ошибку

Traceback (most recent call last):
  File "test.py", line 27, in <module>
    d: {try: this}''')
  File "/opt/homebrew/lib/python2.7/site-packages/yaml/__init__.py", line 71, in load
    return loader.get_single_data()
  File "/opt/homebrew/lib/python2.7/site-packages/yaml/constructor.py", line 39, in get_single_data
    return self.construct_document(node)
  File "/opt/homebrew/lib/python2.7/site-packages/yaml/constructor.py", line 43, in construct_document
    data = self.construct_object(node)
  File "/opt/homebrew/lib/python2.7/site-packages/yaml/constructor.py", line 88, in construct_object
    data = constructor(self, node)
  File "test.py", line 19, in foo_constructor
    return Foo(s, d, l)
  File "test.py", line 7, in __init__
    self.l1, self.l2 = l
ValueError: need more than 0 values to unpack

Это связано с тем, что конструктор yaml запускается на внешнем слое вложенности до и конструирует объект до того, как все узлы закончены. Есть ли способ изменить порядок и начать сначала с глубоко внедренных (например, вложенных) объектов? В качестве альтернативы, есть ли способ заставить конструкцию произойти, по крайней мере, после того, как объекты node были загружены?

Ответ 1

Ну, что ты знаешь. Решение, которое я нашел, было настолько простым, но не очень хорошо документированным.

Документация класса загрузчика ясно показывает, что метод construct_mapping принимает только один параметр (node). Однако, рассмотрев возможность написания собственного конструктора, я проверил источник, и ответ был прямо там! Метод также принимает параметр deep (по умолчанию False).

def construct_mapping(self, node, deep=False):
    #...

Итак, правильный метод конструктора для использования -

def foo_constructor(loader, node):
    values = loader.construct_mapping(node, deep=True)
    #...

Думаю, PyYaml может использовать дополнительную документацию, но я благодарен, что она уже существует.

Ответ 2

ТЛ; др:
замените ваш foo_constructor на тот, что находится в коде внизу этого ответа


Существует несколько проблем с вашим кодом (и вашим решением), позволяющим обращаться к ним шаг за шагом.

Приведенный вами код не будет печатать то, что он говорит в комментарии в нижней строке, ('Foo(1, {'try': 'this'}, [1, 2])'), поскольку для Foo не существует __str__(), он печатает что-то вроде:

__main__.Foo object at 0x7fa9e78ce850

Это легко устранить, добавив следующий метод к Foo:

    def __str__(self):
        # print scalar, dict and list
        return('Foo({s}, {d}, {l})'.format(**self.__dict__))

и если вы посмотрите на вывод:

Foo(1, [1, 2], {'try': 'this'})

Это близко, но не то, что вы обещали в комментарии. list и dict меняются местами, потому что в foo_constructor() вы создаете Foo() с неправильным порядком параметров.
Это указывает на более фундаментальную проблему, которую ваш foo_constructor() должен знать о объекте, который он создает. Почему это так? Это не просто порядок параметров, попробуйте:

f = yaml.load('''
--- !Foo
s: 1
l: [1, 2]
''')

print(f)

Можно ожидать, что это напечатает Foo(1, None, [1, 2]) (со значением по умолчанию для не заданного аргумента ключевого слова d).
Вы получаете исключение KeyError на d = value['d'].

Вы можете использовать get('d') и т.д. в foo_constructor(), чтобы решить эту проблему, но вы должны понимать, что для правильного поведения вы должны задавать значения по умолчанию из вашего Foo.__init__() ( которые в вашем случае просто все None), для каждого параметра со значением по умолчанию:

def foo_constructor(loader, node):
    values = loader.construct_mapping(node, deep=True)
    s = values["s"]
    d = values.get("d", None)
    l = values.get("l", None)
    return Foo(s, l, d)

сохранение этого обновления - это, конечно, кошмар для обслуживания.

Итак, отбросьте весь foo_constructor и замените его чем-то, что больше похоже на то, как PyYAML делает это внутри:

def foo_constructor(loader, node):
    instance = Foo.__new__(Foo)
    yield instance
    state = loader.construct_mapping(node, deep=True)
    instance.__init__(**state)

Это обрабатывает отсутствующие параметры (по умолчанию) и не нуждается в обновлении, если значения по умолчанию для ваших аргументов ключевого слова меняются.

Все это в полном примере, включая самореляционное использование объекта (всегда сложное):

class Foo(object):
    def __init__(self, s, l=None, d=None):
        self.s = s
        self.l1, self.l2 = l
        self.d = d

    def __str__(self):
        # print scalar, dict and list
        return('Foo({s}, {d}, [{l1}, {l2}])'.format(**self.__dict__))

def foo_constructor(loader, node):
    instance = Foo.__new__(Foo)
    yield instance
    state = loader.construct_mapping(node, deep=True)
    instance.__init__(**state)

yaml.add_constructor(u'!Foo', foo_constructor)

print(yaml.load('''
--- !Foo
s: 1
l: [1, 2]
d: {try: this}'''))
print(yaml.load('''
--- !Foo
s: 1
l: [1, 2]
'''))
print(yaml.load('''
&fooref
a: !Foo
  s: *fooref
  l: [1, 2]
  d: {try: this}
''')['a'])

дает:

Foo(1, {'try': 'this'}, [1, 2])
Foo(1, None, [1, 2])
Foo({'a': <__main__.Foo object at 0xba9876543210>}, {'try': 'this'}, [1, 2])

Это было протестировано с помощью ruamel.yaml (из которых я являюсь автором), что является расширенной версией PyYAML. Решение должно работать одинаково для самого PyYAML.