[article] python dynamic enum

This commit is contained in:
motius 2020-06-02 21:59:23 +02:00
parent 001420ea8d
commit 57026c7fa5
1 changed files with 160 additions and 0 deletions

View File

@ -0,0 +1,160 @@
---
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