161 lines
6.9 KiB
Markdown
161 lines
6.9 KiB
Markdown
|
---
|
||
|
title: Créer un enum Python dynamiquement
|
||
|
date: 2020-06-02
|
||
|
author: motius
|
||
|
template: post
|
||
|
tags: python,enum,type,développement
|
||
|
---
|
||
|
|
||
|
Bonjour !
|
||
|
|
||
|
Aujourd'hui, je vous propose un micro tutoriel pour créer un `enum` dynamiquement en Python.
|
||
|
Je vous accorde qu'on ne fait pas ça tous les jours, c'est d'ailleurs la première fois que je suis tombé sur ce cas.
|
||
|
|
||
|
# Le scénario
|
||
|
|
||
|
Je souhaitais pouvoir m'interfacer avec un service qui définit un grand nombre (un peu plus d'une centaine) de constantes qu'il me renvoie sous forme d'une collection de chaînes de caractères.
|
||
|
Trois choix s'offraient ainsi à moi :
|
||
|
|
||
|
* utiliser les constantes telles quelles dans une collection (`list`, `dict`...)
|
||
|
|
||
|
**Avantages** :
|
||
|
* rapide & facile
|
||
|
|
||
|
**Inconvénients** :
|
||
|
* sémantiquement pauvre
|
||
|
* la liste des états valides n'est jamais écrite dans le code
|
||
|
|
||
|
* faire un copié collé et du formatage (avec vim, c'est facile et rapide)
|
||
|
|
||
|
**Avantages** :
|
||
|
* rapide & facile
|
||
|
* l'intégralité des valeurs connues de l'`enum` peuvent être lues dans le programme à la seule lecture du code
|
||
|
|
||
|
**Inconvénients** :
|
||
|
* duplique un `enum` dont je ne suis pas responsable
|
||
|
|
||
|
* générer dynamiquement l'`enum`
|
||
|
|
||
|
**Avantages** :
|
||
|
* résout les inconvénients des solutions précédentes
|
||
|
* évite la duplication de code inutile.
|
||
|
Il arrive assez souvent que l'on doive définir un même `enum` à plusieurs endroits dans un système informatique, par exemple en base de donnée dans Postgre, puis en Java / Python, et enfin en Typescript si vous faites du développement sur toute la stack.
|
||
|
Mais dans ce cas-ci, j'aurais redéfini un enum sans que mon code ne soit la source d'autorité sur celui-ci, ce qui contrevient au principe du Single Source of Truth, c'est donc plus gênant que le simple problème de duplication de code.
|
||
|
* Plus facile à adapter lors de l'évolution de l'`enum`.
|
||
|
Ce dernier point est vrai dans mon cas où j'ai l'assurance que l'API ne fera qu'augmenter les états et n'invalidera jamais un état existant.
|
||
|
Dans le cas contraire, il faudra changer les fonctions qui utilisent les états définis dans l'`enum`, avec ou sans la génération d'`enum` automatique.
|
||
|
|
||
|
**Inconvénients** :
|
||
|
|
||
|
* à nouveau, la liste des états valides n'est jamais écrite dans le code
|
||
|
* Un tantinet plus long, surtout si tout ne va pas sur des roulettes du premier coup.
|
||
|
|
||
|
Et si je mentionne les roulettes, c'est que vous imaginez bien que ça n'a pas marché du premier coup (sinon je n'écrirais pas cet article).
|
||
|
|
||
|
# La théorie
|
||
|
|
||
|
Selon la [documentation officielle](https://docs.python.org/3/library/functions.html#type "Documentation Python3 sur type"), il suffit de passer 3 paramètres à la fonction `type` afin de créer une classe dynamiquement.
|
||
|
Si vous voulez mon avis, on est là dans la catégorie des fonctionnalités de Python vraiment puissantes pour le prototypage (à côté de `eval` et `exec`, même si ces derniers devraient être quasiment _interdits_ en production).
|
||
|
|
||
|
Chouette, me dis-je en mon fort intérieur, ceci devrait marcher :
|
||
|
|
||
|
```python
|
||
|
# récupération du dictionnaire des valeurs
|
||
|
d_enum_values: dict = fetch_all_states(...)
|
||
|
# création dynamique de l'enum
|
||
|
MonEnumCustom = type("MonEnumCustom", (enum.Enum,), d_enum_values)
|
||
|
```
|
||
|
|
||
|
# La pratique
|
||
|
|
||
|
Ne vous fatiguez pas, ça ne marche pas. La solution que j'ai trouvée est un contournement dégoûtant (dû justement au module `enum` de Python).
|
||
|
|
||
|
Je contourne les problèmes de création d'`enum` à l'aide d'un mapper qui ressemble à ça :
|
||
|
```python
|
||
|
class EnumMappingHelper(dict): # héritage pour contourner type
|
||
|
def __init__(self, mapping: dict = None):
|
||
|
self._mapping = mapping
|
||
|
|
||
|
def __getitem__(self, item):
|
||
|
if item == "_ignore_": # contournement d'enum
|
||
|
return []
|
||
|
return self._mapping[item]
|
||
|
|
||
|
def __delitem__(self, item):
|
||
|
return item # contournement d'enum
|
||
|
|
||
|
def __getattr__(self, key): # Accès au clefs de l'enum par attribut
|
||
|
return self._mapping[key]
|
||
|
|
||
|
@property
|
||
|
def _member_names(self): # contournement d'enum
|
||
|
return dict(self._mapping)
|
||
|
|
||
|
enum_mapping = EnumMappingHelper(d_enum_values)
|
||
|
MonEnumCustom = type("MonEnumCustom", (enum.Enum,), enum_mapping)
|
||
|
```
|
||
|
|
||
|
Je m'en sors donc en torturant un peu un classe personnalisée que j'appelle `EnumMappingHelper` afin que celle-ci se comporte d'une façon qui soit acceptable à la fois pour la fonction `type` et pour le module `enum`.
|
||
|
|
||
|
Je peux utiliser les variantes de mon `enum` ainsi :
|
||
|
```python
|
||
|
if state is MonEnumCustom.VALIDATION:
|
||
|
print("En cours de validation")
|
||
|
elif state is MonEnumCustom.COMMIT:
|
||
|
print("Validé")
|
||
|
elif state is MonEnumCustom.SEND:
|
||
|
print("Aucune modification possible")
|
||
|
else:
|
||
|
...
|
||
|
```
|
||
|
|
||
|
# Un petit plus... avec un mixin
|
||
|
|
||
|
Je dispose bien entendu d'une fonction qui permet de mapper un état sous forme de chaîne de caractères à la valeur représentée dans l'`enum` de référence.
|
||
|
Moralement et en très très gros, elle ressemble à ça :
|
||
|
```python
|
||
|
@functools.lru_cache
|
||
|
def map_str_to_custom_enum(s: str) -> MonEnumCustom:
|
||
|
"""docstring
|
||
|
"""
|
||
|
return dict((v.value, v) for v in MonEnumCustom)[s]
|
||
|
```
|
||
|
L'idée étant donc de faire le mapping inverse de celui fourni par '`enum`, _i.e._ de générer automatiquement et efficacement une variante d'`enum` à partir de sa représentation sous forme de chaîne de caractères.
|
||
|
|
||
|
Sauf qu'en réalité, la fonction n'est pas du tout implémentée ainsi.
|
||
|
À la place, j'utilise un mixin, ce qui me permet d'avoir la même fonctionnalité sur tous les `enum`.
|
||
|
La fonction ci-dessus est remplacée par une méthode de classe.
|
||
|
Le cache `lru_cache` est remplacé par un attribut de classe de type dictionnaire, ce qui évite toutes sortes d'inconvénients.
|
||
|
```python
|
||
|
class EnumMixin:
|
||
|
_enum_values = {}
|
||
|
def __init__(self, *args, **kwargs):
|
||
|
self.__class__._enum_values[args[0]] = self
|
||
|
|
||
|
@classmethod
|
||
|
def convert_str_to_enum_variant(cls, value: str):
|
||
|
cls._enum_values
|
||
|
return cls._enum_values(value, object())
|
||
|
```
|
||
|
|
||
|
Avec le mixin de conversion, le code de génération d'`enum` devient :
|
||
|
```python
|
||
|
# ceci n'a pas changé
|
||
|
enum_mapping = EnumMappingHelper(d_enum_values)
|
||
|
# cela si
|
||
|
MonEnumCustom = type("MonEnumCustom", (EnumMixin, enum.Enum), enum_mapping)
|
||
|
```
|
||
|
|
||
|
# Conclusion
|
||
|
|
||
|
Seule la solution utilisant un copier coller permettait de voir la liste des valeurs de l'`enum` dans le code.
|
||
|
Je combine les avantages de cette solution et de la troisième que j'ai implémentée en copiant les valeurs prises par l'`enum` dans la docstring de sa classe et en mentionnant la version du service distant associé.
|
||
|
Cela me permet de combiner les avantages de toutes les solutions à l'exception, bien sûr, d'un peu de temps passé.
|
||
|
|
||
|
L'inconvénient inattendu de ce code, c'est celui de devoir créer une classe d'aide dont le seul but soit de forcer un comportement afin d'obtenir le résultat escompté.
|
||
|
Si vous avez des suggestions pour améliorer ce bazar, je prends.
|
||
|
|
||
|
Joyeux code !
|
||
|
|
||
|
Motius
|