web-dev-qa-db-fra.com

Python classe de données de dict

La bibliothèque standard de la version 3.7 peut convertir de manière récursive une classe de données en un dict (exemple tiré de la documentation):

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

Je cherche un moyen de transformer un dict en une classe de données en cas d'imbrication. Quelque chose comme C(**tmp) ne fonctionne que si les champs de la classe de données sont des types simples et non eux-mêmes des classes de données. Je connais jsonpickle , qui est toutefois accompagné d’un avertissement de sécurité important.

34
mbatchkarov

Vous trouverez ci-dessous l'implémentation CPython de asdict - ou plus précisément, de la fonction d'assistance interne récursive _asdict_inner qu'elle utilise:

# 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 appelle simplement ce qui précède avec quelques assertions, et dict_factory=dict par défaut.

Comment cela peut-il être adapté pour créer un dictionnaire de sortie avec le marquage de type requis, comme mentionné dans les commentaires?


1. Ajout d'informations de type

Ma tentative a consisté à créer un wrapper de retour personnalisé héritant de 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

En regardant le code original, seule la première clause doit être modifiée pour utiliser ce wrapper, car les autres clauses ne gèrent que les conteneurs de 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)

Importations:

from dataclasses import dataclass, fields, is_dataclass

# thanks to Patrick Haugh
from typing import *

# deepcopy 
import copy

Fonctions utilisées:

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

Tests avec les exemples de classes de données:

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

Les résultats sont comme prévu.


2. Reconvertir en dataclass

La routine récursive utilisée par asdict peut être réutilisée pour le processus inverse, avec quelques modifications relativement mineures:

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)

Fonctions utilisées:

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)

Tester:

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

Encore une fois comme prévu.

16
meowgoesthedog

Je suis l'auteur de dacite - l'outil qui simplifie la création de classes de données à partir de dictionnaires.

Cette bibliothèque n'a qu'une fonction from_dict - voici un exemple d'utilisation rapide:

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)

De plus, dacite prend en charge les fonctionnalités suivantes:

  • structures imbriquées
  • vérification des types (de base)
  • champs optionnels (c'est-à-dire en tapant.Optional)
  • les syndicats
  • collections
  • valeurs casting et transformation
  • remappage des noms de champs

... et c'est bien testé - couverture de code à 100%!

Pour installer dacite, utilisez simplement pip (ou pipenv):

$ pip install dacite
20
Konrad Hałas

Vous pouvez utiliser mashumaro pour créer un objet dataclass à partir d'un dict selon le schéma. Mixin de cette bibliothèque ajoute des méthodes pratiques from_dict et to_dict à des classes de données:

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

Tout ce qu'il faut, c'est un cinq lignes:

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

Exemple d'utilisation:

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

Code complet, y compris vers/depuis json, ici chez Gist: https://Gist.github.com/gatopeich/1efd3e1e4269e1e98fae9983bb914f22

3
gatopeich

Si votre objectif est de produire JSON à partir de et vers des classes de données existantes prédéfinies , il suffit d'écrire des crochets de codeur et de décodeur personnalisés . N'utilisez pas dataclasses.asdict() ici, mais enregistrez en JSON une référence (sécurisée) à la classe de données d'origine.

jsonpickle n'est pas sûr car il stocke les références à des objets arbitraires Python et transmet les données à leurs constructeurs. Avec de telles références, jsonpickle peut faire référence à des structures de données internes Python et créer et exécuter des fonctions, des classes et des modules à volonté. Mais cela ne signifie pas que vous ne pouvez pas gérer de telles références de manière non sécurisée. Vérifiez simplement que vous importez (et non pas appelez), puis vérifiez que l'objet est un type de classe de données réel avant de l'utiliser.

La structure peut être rendue suffisamment générique, mais néanmoins limitée aux types JSON-serialisable plus instances 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

Ceci utilise indications de classe de style JSON-RPC pour nommer la classe de données. Lors du chargement, il est vérifié que cette classe de données contient toujours les mêmes champs. Aucune vérification de type n'est effectuée sur les valeurs des champs (car il s'agit d'une marmite de poissons totalement différente).

Utilisez-les comme arguments default et object_hook pour json.dump[s]() et 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

ou créez des instances des classes JSONEncoder et JSONDecoder avec les mêmes points d'ancrage.

Au lieu d'utiliser des noms de module et de classe entièrement éligibles, vous pouvez également utiliser un registre distinct pour mapper les noms de types autorisés; vérifiez le registre sur l'encodage, et encore sur le décodage pour vous assurer de ne pas oublier d'enregistrer des classes de données au fur et à mesure que vous développez.

3
Martijn Pieters

ndictify est une bibliothèque qui pourrait être utile. Voici un exemple d'utilisation minimale:

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)
0
Tobias Hermann