Des enumérations encore plus puissantes avec Python 3.11

pgradot

Pierre Gradot

Posted on June 8, 2023

Des enumérations encore plus puissantes avec Python 3.11

Python 3.11 est sorti à la fin de l'année dernière. Comme souvent, il y a beaucoup de nouveautés. Une section a plus attiré mon œil que les autres : il y a eu beaucoup de changements et d'ajouts sur les enums ! Bon, la vérité, c'est que je cherchais comment faire quelque chose d'assez spécifique et j'ai vu que Python 3.11 apportait justement cette fonctionnalité... J'ai bien sûr immédiatement mis à jour mon interpréteur pour tester ça !

Dans cet article, je vous présente les nouveautés qui me semblent les plus prometteuses.

3.11 : une version importante pour le module enum

Le module enum est très stable depuis son apparition en version 3.4 et l'implémentation de la PEP 435.

Au début de la documentation, on voit :

New in version 3.6: Flag, IntFlag, auto

New in version 3.11: StrEnum, EnumCheck, ReprEnum, FlagBoundary, property, member, nonmember, global_enum, show_flag_values

La version 3.11 est donc une version qui apporte beaucoup de nouveautés. 9 sont listées au début de la documentation, mais il y en a une 10ᵉ qu'on trouve plus bas : verify(). En vrai, la documentation est loin d'être parfaite, mais on s'en sort.

Streets of Rage

Pour le fun, j'ai décidé d'utiliser des exemples basés sur Streets of Rage. Quoi ?! Tu connais pas Streets of Rage ?! Mais fonce vite agrandir ta culture pop !

Mon épisode préféré est clairement le 2, mais j'utiliserai un peu le 1 aussi !

Ce qu'on pouvait déjà faire avant Python 3.11

Si on souhaite lister les niveaux de Streets of Rage 2, il est possible de faire une énumération comme celle-ci depuis Python 3.4 :

class Stages(Enum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 3
    STADIUM = 4
    # ... et plusieurs autres encore !
Enter fullscreen mode Exit fullscreen mode

Elles sont très permissives et on peut par exemple faire quelque chose comme :

class Stages(Enum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 'three'
    STADIUM = [4]
Enter fullscreen mode Exit fullscreen mode

Il est donc possible d'avoir des valeurs de types différents. Ça peut être pratique dans certains cas, mais on souhaite en général imposer le type des valeurs, comme dans notre exemple dans lequel chaque niveau correspond à un numéro. C'est pour cette raison que IntEnum a été introduite en Python 3.6 :

class Stages(IntEnum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 3
    STADIUM = 'four'
Enter fullscreen mode Exit fullscreen mode

On obtient une exception à l'exécution : ValueError: invalid literal for int() with base 10: 'four'.

Notez que si on a STADIUM = '4' (notez les simple quotes autour du 4), le code fonctionne. En effet, comme l'indique l'exception, IntEnum utilise int() pour obtenir la valeur et il s'avère que int('4') == 4. On peut ainsi utiliser comme initializer une instance d'une classe qui fournit une méthode def __int__(self) -> int.

IntEnum est en fait une "mixed enum". Le principe des mixed enums est de faire un héritage (multiple) d'un type souhaité T et d'enum. Ce n'est pas très bien documenté à mon goût, mais on trouve des explications dans le "Enum HOWTO" (ici et un peu ). On obtient ainsi une énumération dont les valeurs sont obligatoirement du même type T.

Après ces rappels, on va s'attarder dans la suite aux changements apportés par la version 3.11.

ReprEnum

Si on hérite ReprEnum plutôt que Enum, on créé une énumération pour laquelle la conversion en string de ses valeurs sera alors la même que la conversion du mixed-in type. La documentation précise :

ReprEnum uses the repr() of Enum, but the str() of the mixed-in data type.

(...)

Inherit from ReprEnum to keep the str() / format() of the mixed-in data type instead of using the Enum-default str().

L'affichage des IntEnums change à cause de ReprEnum

What’s New In Python 3.11 nous dit :

Changed IntEnum (...) to now inherit from ReprEnum, so their str() output now matches format() (both str(AnIntEnum.ONE) and format(AnIntEnum.ONE) return '1', whereas before str(AnIntEnum.ONE) returned 'AnIntEnum.ONE'.

Regardons ce que ça donne avec notre énumération Stages(IntEnum) :

print('member\t', Stages.DOWNTOWN)
print('name\t', Stages.DOWNTOWN.name)
print('value\t', Stages.DOWNTOWN.value)
print('str()\t', str(Stages.DOWNTOWN))
print('repr()\t', repr(Stages.DOWNTOWN))
print('f-str\t', f'{Stages.DOWNTOWN}')
Enter fullscreen mode Exit fullscreen mode

En 3.10 :

member   Stages.DOWNTOWN
name     DOWNTOWN
value    1
str()    Stages.DOWNTOWN
repr()   <Stages.DOWNTOWN: 1>
f-str    1
Enter fullscreen mode Exit fullscreen mode

Affichage modifié en 3.11 :

member   1
name     DOWNTOWN
value    1
str()    1
repr()   <Stages.DOWNTOWN: 1>
f-str    1
Enter fullscreen mode Exit fullscreen mode

Personnellement, je trouve ça plus logique, mais ce changement peut avoir des conséquences sur un code existant !

StrEnum, pour faire comme IntEnum mais avec des strings

On a souvent besoin de faire une énumération qui ne contient que des strings, par exemple pour lister les personnages du jeu :

class Characters(StrEnum):
    AXEL = 'Axel Stone'
    BLAZE = 'Blaze Fielding'
    MAX = 'Max Thunder'
    SKATE = 'Eddie "Skate" Hunter'
Enter fullscreen mode Exit fullscreen mode

StrEnum hérite de ReprEnum, ce qui implique que print(str(Characters.BLAZE)) et print(f'{Characters.BLAZE}') affichent Blaze Fielding. Si on avait fait Characters(Enum), l'affichage aurait donné Characters.BLAZE. Comme pour IntEnum, je trouve cet affichage logique.

On peut utiliser auto() avec StrEnum :

class Characters(StrEnum):
    # ...
    SKATE = auto()
Enter fullscreen mode Exit fullscreen mode

str(Characters.SKATE)) sera alors skate.

Il était déjà possible de faire un équivalent de StrEnum avant Python 3.11, avec une simple enum mais le typage était moins fort. On pouvait par exemple faire :

class Characters(str, Enum):
    AXEL = 'Axel Stone'
    BLAZE = 'Blaze Fielding'
    MAX = 'Max Thunder'
    SKATE = 8
Enter fullscreen mode Exit fullscreen mode

Et ça passait crème. En effet, il est possible de construire une string à partir de 8 avec str(8). Ce n'est pas dit dans la doc, mais on peut regarder l'implémentation de StrEnum dans enum.py et on voit que le constructeur est redéfini et vérifie explicitement le typage avec des isinstance(..., str). Ce n'est pas le cas de IntEnum.

Plus de vérifications avec le décorateur @verify

@unique est présent depuis le début du module enum et permet de s'assurer que chaque membre a une valeur... unique ! 😂

C'est très bien pour définir les niveaux du jeu et s'assurer qu'ils ont tous un numéro différent. Exemple :

@unique
class Stages(IntEnum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 3
    STADIUM = 3
Enter fullscreen mode Exit fullscreen mode

Ce code génère une exception : ValueError: duplicate values found in <enum 'Stages'>: STADIUM -> AMUSEMENT_PARK.

Un nouveau décorateur, @verify, est apparu en 3.11 :

A class decorator specifically for enumerations. Members from EnumCheck are used to specify which constraints should be checked on the decorated enumeration.

Il prend donc en paramètres des EnumChecks :

EnumCheck contains the options used by the verify() decorator to ensure various constraints; failed constraints result in a ValueError.

Seuls UNIQUE, CONTINUOUS et NAMED_FLAGS sont disponibles pour le moment. @verify(UNIQUE) est équivalent à @unique.

On peut passer plusieurs flags en paramètres, ce qui est parfait pour notre exemple :

@verify(UNIQUE, CONTINUOUS)
class Stages(IntEnum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 3
    STADIUM = 5
Enter fullscreen mode Exit fullscreen mode

Une exception nous prévient qu'il manque une valeur : ValueError: invalid enum 'Stages': missing values 4.

Rendre les membres accessibles dans le namespace global

Pour accéder à un membre, il faut normalement y accéder via la classe : Stages.STADIUM.

Dans certains cas (et avec les éventuels risques de name clashes qui vont avec), vous pourriez souhaitez utiliser directement STADIUM. C'est possible à partir de Python 3.11, en annotant votre classe avec @global_enum.

Contrôler ce qui est membre et ce qui n'est pas membre

Deux nouveaux décorateurs permettent de contrôler explicitement ce qui est membre de l'énumération et ce qui ne l'est pas :

@enum.member
A decorator for use in enums: its target will become a member.

@enum.nonmember
A decorator for use in enums: its target will not become a member.

Quand on parle de membres d'une énumération, on parle de ses différentes valeurs possibles.

Ce décorateur @member est très pratique pour définir une énumération dont les valeurs sont des fonctions.

Pour Streets of Rage 2, il nous faut par exemple une énumération des 3 actions de base que peut faire un personnage. Une fonction est un bon type pour représenter une action. Par défaut, une fonction définie dans une classe dérivant de Enum est une static method. Ainsi, le code suivant ne fait pas ce qu'on souhaiterait, car il crée une énumération sans valeur :

class Controls(Enum):
    def special_move():
        print('Special move, massive damage!')

    def attack():
        print('Attack? OK! Punch!')

    def jump():
        print('The floor is lava! Jump!')

print(list(Controls))
Controls.attack()
Enter fullscreen mode Exit fullscreen mode

Ce code affiche :

[]
Attack? OK! Punch!
Enter fullscreen mode Exit fullscreen mode

Pour corriger ça, il suffit d'annoter les fonctions :

class Controls(Enum):
    @member
    def special_move():
        print('Special move, massive damage!')

    @member
    def attack():
        print('Attack? OK! Punch!')

    @member
    def jump():
        print('The floor is lava! Jump!')


print(list(Controls))
Controls.attack.value()
Enter fullscreen mode Exit fullscreen mode

On obtient cette fois :

[<Controls.special_move: <function Controls.special_move at 0x0000015B0080AD40>>,
        <Controls.attack: <function Controls.attack at 0x0000015B00778680>>,
        <Controls.jump: <function Controls.jump at 0x0000015B00822200>>]
Attack? OK! Punch!
Enter fullscreen mode Exit fullscreen mode

Parfait ! Notez bien que Controls.attack n'est pas callable (car c'est le membre de l'énumération) et qu'il faut utiliser .value pour accéder réellement à la fonction.

À l'inverse, si vous voulez qu'une donnée soit statique à la classe, il faut utiliser @nonmember(). La syntaxe est un peu surprenante (je trouve) et la documentation officielle n'en donne aucun exemple. En voici donc un petit pour la route :

class Characters(StrEnum):
    playable = nonmember(True)
    AXEL = 'Axel Stone'
    BLAZE = 'Blaze Fielding'
    MAX = 'Max Thunder'
    SKATE = 'Eddie "Skate" Hunter'


print(Characters.playable)
Enter fullscreen mode Exit fullscreen mode

Comme toujours en Python, un champ de la classe est accessible via ses membres, donc on peut utiliser Characters.SKATE.playable.

Conclusion

Il y a beaucoup de nouveautés intéressantes dans cette version de Python 3.11 ! Quand ton langage principal est C++, où les enumérations sont vraiment très basiques, tu es comme un enfant dans un magasin de bonbons ! Je regrette quand même une documentation pas ouf (certaines features sont très mal, voire pas documentées) et des trucs trop bizarres (comme show_flag_values() qui n'est pas ajouté à __all__ et dont l'utilisabilité est vraiment mauvaise). Gageons que ça s'améliorera dans les prochaines versions et profitons dès maintenant de cette puissance supplémentaire dans le package enum !

💖 💪 🙅 🚩
pgradot
Pierre Gradot

Posted on June 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related