Класс данных Python из вложенного dict

Стандартная библиотека в 3.7 может рекурсивно преобразовывать класс данных в dict (пример из документов):

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

Ниже приведена реализация 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 по умолчанию.

Как это можно адаптировать для создания выходного словаря с необходимыми тегами типа, как указано в комментариях?


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:

# 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)])

Снова, как и ожидалось.

Ответ 2

Я автор 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

Ответ 3

Вы можете использовать mashumaro для создания объекта класса данных из dict согласно схеме. Mixin из этой библиотеки добавляет удобные from_dict и to_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

Ответ 4

Все, что нужно, - это пятислойный лайнер:

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

Ответ 5

Если ваша цель состоит в том, чтобы создавать JSON из существующих и заранее заданных классов данных, а затем просто написать пользовательские кодировщики и обработчики декодеров. Не используйте здесь dataclasses.asdict(), вместо этого запишите в JSON (безопасную) ссылку на исходный класс данных.

jsonpickle небезопасен, потому что он хранит ссылки на произвольные объекты Python и передает данные их конструкторам. С помощью таких ссылок я могу получить jsonpickle для ссылки на внутренние структуры данных Python, а также по желанию создавать и выполнять функции, классы и модули. Но это не значит, что вы не можете обращаться с такими ссылками небезопасно. Просто убедитесь, что вы только импортируете (не вызываете), а затем убедитесь, что объект является действительным типом класса данных, прежде чем использовать его.

Фреймворк можно сделать достаточно универсальным, но все же ограничить его только сериализуемыми JSON типами и dataclass -based:

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, и при загрузке проверяется, что он по-прежнему является классом данных с теми же полями. Не проверяется тип по значениям полей (так как это совершенно другой котелок с рыбой).

Используйте их в качестве аргументов по default и object_hook для json.dump[s]() и json.dump[s]():

>>> 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

или создайте экземпляры классов JSONEncoder и JSONDecoder с теми же хуками.

Вместо использования полностью подходящих имен модулей и классов, вы также можете использовать отдельный реестр для сопоставления допустимых имен типов; проверьте реестр на кодировку и еще раз на декодирование, чтобы убедиться, что вы не забыли зарегистрировать классы данных при разработке.

Ответ 6

undeictify - это библиотека, которая может помочь. Вот минимальный пример использования:

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)