Des enumérations encore plus puissantes avec Python 3.11
Tags: programming, todayilearned, python
Published: 2023-06-08 14:27:10

Python 3.11 est sorti à la fin de l'année dernière. Comme souvent, il y a [beaucoup de nouveautés](https://docs.python.org/3/whatsnew/3.11.html). Une section a plus attiré mon œil que les autres : il y a eu [beaucoup de changements et d'ajouts sur les enums](https://docs.python.org/3/whatsnew/3.11.html#enum) ! 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`](https://docs.python.org/3/library/enum.html) est très stable depuis son apparition en [version 3.4](https://www.python.org/downloads/release/python-340/) et l'implémentation de la [PEP 435](https://peps.python.org/pep-0435/).

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](https://github.com/python/cpython/issues/98298), 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](https://en.wikipedia.org/wiki/Streets_of_Rage_2) ta culture pop !

Mon épisode préféré est clairement le [2](https://streetsofrage.fandom.com/wiki/Streets_of_Rage_2), mais j'utiliserai un peu le [1](https://streetsofrage.fandom.com/wiki/Streets_of_Rage) 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 :

```python
class Stages(Enum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 3
    STADIUM = 4
    # ... et plusieurs autres encore !
```

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

```python
class Stages(Enum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 'three'
    STADIUM = [4]
```

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 :

```python
class Stages(IntEnum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 3
    STADIUM = 'four'
```

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"](https://docs.python.org/3/howto/enum.html) ([ici](https://docs.python.org/3/howto/enum.html#others) et un peu [là](https://docs.python.org/3/howto/enum.html#restricted-enum-subclassing)). 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](https://docs.python.org/3.11/library/enum.html#enum.ReprEnum) 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 `IntEnum`s change à cause de `ReprEnum`

[What’s New In Python 3.11](https://docs.python.org/3/whatsnew/3.11.html) 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)` :

```python
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}')
```

En 3.10 :

```raw
member	 Stages.DOWNTOWN
name	 DOWNTOWN
value	 1
str()	 Stages.DOWNTOWN
repr()	 <Stages.DOWNTOWN: 1>
f-str	 1
```

Affichage modifié en 3.11 :

```raw
member	 1
name	 DOWNTOWN
value	 1
str()	 1
repr()	 <Stages.DOWNTOWN: 1>
f-str	 1
```

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 :

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

`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` :

```python
class Characters(StrEnum):
    # ...
    SKATE = auto()
```

`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 :

```python
class Characters(str, Enum):
    AXEL = 'Axel Stone'
    BLAZE = 'Blaze Fielding'
    MAX = 'Max Thunder'
    SKATE = 8
```

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 :

```python
@unique
class Stages(IntEnum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 3
    STADIUM = 3
```

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

Un nouveau décorateur, [`@verify`](https://docs.python.org/3/library/enum.html#enum.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 [`EnumCheck`s](https://docs.python.org/3/library/enum.html#enum.EnumCheck) :

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

Seuls [`UNIQUE`](https://docs.python.org/3/library/enum.html#enum.EnumCheck.UNIQUE), [`CONTINUOUS`](https://docs.python.org/3/library/enum.html#enum.EnumCheck.CONTINUOUS) et [`NAMED_FLAGS`](https://docs.python.org/3/library/enum.html#enum.EnumCheck.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 :

```python
@verify(UNIQUE, CONTINUOUS)
class Stages(IntEnum):
    DOWNTOWN = 1
    BRIDGE_CONSTRUCTION = 2
    AMUSEMENT_PARK = 3
    STADIUM = 5
```

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](https://docs.python.org/3.11/library/enum.html#enum.member) 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 :

```python
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()
```

Ce code affiche :

```raw
[]
Attack? OK! Punch!
```

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

```python
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()
```

On obtient cette fois :

```raw
[<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!
```

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 :

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


print(Characters.playable)
```

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](https://en.cppreference.com/w/cpp/language/enum) 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()](https://docs.python.org/3/library/enum.html#enum.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` !