web-dev-qa-db-fra.com

Création d'objets de classes de données imbriqués dans Python

J'ai un objet de classe de données qui contient des objets de classe de données imbriqués. Cependant, lorsque je crée l'objet principal, les objets imbriqués se transforment en dictionnaire:

@dataclass
class One:
    f_one: int

@dataclass
class One:
    f_one: int
    f_two: str

@dataclass
class Two:
    f_three: str
    f_four: One


data = {'f_three': 'three', 'f_four': {'f_one': 1, 'f_two': 'two'}}

two = Two(**data)

two
Two(f_three='three', f_four={'f_one': 1, 'f_two': 'two'})

obj = {'f_three': 'three', 'f_four': One(**{'f_one': 1, 'f_two': 'two'})}

two_2 = Two(**data)

two_2
Two(f_three='three', f_four={'f_one': 1, 'f_two': 'two'})

Comme vous pouvez le voir, j'ai essayé de transmettre toutes les données sous forme de dictionnaire, mais je n'ai pas obtenu le résultat souhaité. Ensuite, j'ai essayé de construire l'objet imbriqué en premier et de le passer par le constructeur d'objet, mais j'ai obtenu le même résultat.

Idéalement, j'aimerais construire mon objet pour obtenir quelque chose comme ceci:

Two(f_three='three', f_four=One(f_one=1, f_two='two'))

Existe-t-il un moyen d'y parvenir autre que la conversion manuelle des dictionnaires imbriqués en objet de classe de données correspondant, chaque fois que vous accédez à des attributs d'objet?

Merci d'avance.

8
mohi666

Il s'agit d'une requête dont la complexité correspond à la complexité du module dataclasses lui-même: ce qui signifie que probablement la meilleure façon d'atteindre cette capacité de "champs imbriqués" est de définir un nouveau décorateur, semblable à @dataclass.

Heureusement, si l'on n'a pas besoin de la signature de la méthode __init__ Pour refléter les champs et leurs valeurs par défaut, comme les classes rendues en appelant dataclass, cela peut être beaucoup plus simple: une classe Le décorateur qui appellera le dataclass d'origine et encapsulera certaines fonctionnalités sur sa méthode __init__ générée peut le faire avec une simple fonction de style "...(*args, **kwargs):".

En d'autres termes, tout ce qu'il faut faire est un wrapper sur la méthode générée __init__ Qui inspectera les paramètres passés dans "kwargs", vérifier si l'un correspond à un "type de champ de classe de données", et si oui, générer l'objet imbriqué avant d'appeler l'original __init__. C'est peut-être plus difficile à préciser en anglais qu'en Python:

from dataclasses import dataclass, is_dataclass

def nested_dataclass(*args, **kwargs):
    def wrapper(cls):
        cls = dataclass(cls, **kwargs)
        original_init = cls.__init__
        def __init__(self, *args, **kwargs):
            for name, value in kwargs.items():
                field_type = cls.__annotations__.get(name, None)
                if is_dataclass(field_type) and isinstance(value, dict):
                     new_obj = field_type(**value)
                     kwargs[name] = new_obj
            original_init(self, *args, **kwargs)
        cls.__init__ = __init__
        return cls
    return wrapper(args[0]) if args else wrapper

Notez qu'en plus de ne pas vous soucier de la signature de __init__, Cela ignore également le passage de init=False - car cela n'aurait aucun sens de toute façon.

(Le if dans la ligne de retour est responsable pour que cela fonctionne soit en étant appelé avec des paramètres nommés soit directement en tant que décorateur, comme dataclass lui-même)

Et sur l'invite interactive:

In [85]: @dataclass
    ...: class A:
    ...:     b: int = 0
    ...:     c: str = ""
    ...:         

In [86]: @dataclass
    ...: class A:
    ...:     one: int = 0
    ...:     two: str = ""
    ...:     
    ...:         

In [87]: @nested_dataclass
    ...: class B:
    ...:     three: A
    ...:     four: str
    ...:     

In [88]: @nested_dataclass
    ...: class C:
    ...:     five: B
    ...:     six: str
    ...:     
    ...:     

In [89]: obj = C(five={"three":{"one": 23, "two":"narf"}, "four": "zort"}, six="fnord")

In [90]: obj.five.three.two
Out[90]: 'narf'

Si vous voulez que la signature soit conservée, je vous recommande d'utiliser les fonctions d'assistance privées dans le module dataclasses lui-même, pour créer un nouveau __init__.

10
jsbueno

Vous pouvez essayer le module dacite . Ce package simplifie la création de classes de données à partir de dictionnaires - il prend également en charge les structures imbriquées.

Exemple:

from dataclasses import dataclass
from dacite import from_dict

@dataclass
class A:
    x: str
    y: int

@dataclass
class B:
    a: A

data = {
    'a': {
        'x': 'test',
        'y': 1,
    }
}

result = from_dict(data_class=B, data=data)

assert result == B(a=A(x='test', y=1))

Pour installer dacite, utilisez simplement pip:

$ pip install dacite
9
Konrad Hałas

Au lieu d'écrire un nouveau décorateur, j'ai proposé une fonction modifiant tous les champs de type dataclass après l'initialisation du dataclass réel.

def dicts_to_dataclasses(instance):
    """Convert all fields of type `dataclass` into an instance of the
    specified data class if the current value is of type dict."""
    cls = type(instance)
    for f in dataclasses.fields(cls):
        if not dataclasses.is_dataclass(f.type):
            continue

        value = getattr(instance, f.name)
        if not isinstance(value, dict):
            continue

        new_value = f.type(**value)
        setattr(instance, f.name, new_value)

La fonction peut être appelée manuellement ou en __post_init__. De cette façon, le @dataclass le décorateur peut être utilisé dans toute sa splendeur.

L'exemple ci-dessus avec un appel à __post_init__:

@dataclass
class One:
    f_one: int
    f_two: str

@dataclass
class Two:
    def __post_init__(self):
        dicts_to_dataclasses(self)

    f_three: str
    f_four: One

data = {'f_three': 'three', 'f_four': {'f_one': 1, 'f_two': 'two'}}

two = Two(**data)
# Two(f_three='three', f_four=One(f_one=1, f_two='two'))
3
Yourstruly

J'ai créé une extension de la solution par @jsbueno qui accepte également la saisie sous la forme List[<your class/>].

def nested_dataclass(*args, **kwargs):
    def wrapper(cls):
        cls = dataclass(cls, **kwargs)
        original_init = cls.__init__

        def __init__(self, *args, **kwargs):
            for name, value in kwargs.items():
                field_type = cls.__annotations__.get(name, None)
                if isinstance(value, list):
                    if field_type.__Origin__ == list or field_type.__Origin__ == List:
                        sub_type = field_type.__args__[0]
                        if is_dataclass(sub_type):
                            items = []
                            for child in value:
                                if isinstance(child, dict):
                                    items.append(sub_type(**child))
                            kwargs[name] = items
                if is_dataclass(field_type) and isinstance(value, dict):
                    new_obj = field_type(**value)
                    kwargs[name] = new_obj
            original_init(self, *args, **kwargs)

        cls.__init__ = __init__
        return cls

    return wrapper(args[0]) if args else wrapper
1
Daan Luttik