From 57026c7fa5447ea4e3e9b88d4985fc2f467d26e2 Mon Sep 17 00:00:00 2001 From: motius Date: Tue, 2 Jun 2020 21:59:23 +0200 Subject: [PATCH] [article] python dynamic enum --- .../posts/creer-enum-python-dynamiquement.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 content/posts/creer-enum-python-dynamiquement.md diff --git a/content/posts/creer-enum-python-dynamiquement.md b/content/posts/creer-enum-python-dynamiquement.md new file mode 100644 index 0000000..7f8df5b --- /dev/null +++ b/content/posts/creer-enum-python-dynamiquement.md @@ -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