Python 3.11 : les exceptions

Python 3.11 a été publié le 24 octobre 2022. La nouveauté la plus intéressante de cette version concerne les exceptions. Il y en a en fait deux : les groupes d’exceptions et la possibilité d’ajouter des notes aux exceptions.

La gestion des exceptions (levée et capture) est un mécanisme indispensable pour tout programme Python robuste. Python a d’ailleurs un bon mécanisme de gestion des exception nous conduisant au principe « Il est plus facile de s’excuser que de demander la permission ».

Le groupe d’exceptions en particulier vient enrichir les possibilités. Les notes sont plus destinées aux outils afin de permettre une remontée d’information par le mécanisme des exceptions.

Je vais vous présenter ces nouveautés dans cet article.

Rappel sur les exceptions

Une exception est levée par le mot-clef raise suivi d’un objet exception qui est une spécialisation (classe enfant) de Exception.

raise ValueError('Error message')

Le paramètre est un message d’erreur qui apparaitra dans la stack trace (ou traceback). Petit rappel évidemment mais indispensable, c’est un message destiné aux développeurs qui doit donc informé sur ce qui s’est passé, mais c’est un message inadapté aux utilisateurs finaux de l’application.

Une exception est levée dans une fonction (donc aussi dans une méthode). En conséquence n’importe quel expression peut lever une exception. Par exemple, les lignes suivantes lèvent toutes deux une exception.

int("Quarante deux")
"un" + 2

Lever une exception permet d’arrêter un traitement que l’on pourrait qualifier d’incohérent. Mais un programme doit aussi pouvoir avoir un comportement face à une levée d’exceptions. Pour cela, il est possible de capturer une exception. Cela se fait avec une structure de type try/except comme l’illustre le code suivant :

try:
    call_function()
except ValueError:
    manage_error()

Une clause try peut déclarer plusieurs clauses except et donc gérer plusieurs types d’exceptions.

Cette structure est déclarée dans la partie du code qui sait comment gérer ce type d’exception. Ainsi, le programme ne s’arrête pas ou s’arrête proprement.

Gérer une erreur à la fois

Le système des exceptions de Python, comme beaucoup de systèmes d’exceptions, est conçu pour ne gérer qu’une seule exception à la fois.

Lorsqu’un bloc de code peut lever plusieurs exceptions, la première arrête l’exécution du programme et est remontée. Il est possible qu’en chemin les exceptions soient chaînées mais c’est tout.

Un bloc try peut avoir plusieurs clauses except et donc capturer plusieurs types d’exception. Cependant, à la première correspondance, la gestion de cette exception sera exécutée et l’interpréteur sortira de la structure.

Grouper les exceptions avec l’exception ExceptionGroup

Python 3.11 a ajouté le type d’exception ExceptionGroup. Il s’agit toujours d’une spécialisation du type Exception mais avec une signature particulière. En effet, cette exception attends en premier argument la classique chaine de caractères et en second une liste d’objets de type Exception.

Nous pouvons donc avoir quelque chose comme :

raise ExceptionGroup("Exception message", [ValueError("message for value error")])

Évidemment, une exception de type ExceptionGroup est destinée à embarquer au moins une exception (une ValueError est levée si la liste est vide) et en général plusieurs exceptions. La liste peut contenir plusieurs exceptions du même type. Par exemple :

raise ExceptionGroup("Input error",
                      [
                         ValueError('Hours must be positive or null'),
                         ValueError('Minutes must be between 0 and 59')
                      ])

En passant, vous avez ici un exemple d’usage de cette ExceptionGroup qui permet de valider plusieurs valeurs et les gérer avec une seule exception. Auparavant, il aurai fallu ruser avec une liste d’alertes qui, si pas vide à la fin, lèverait une seule exception.

La liste des exceptions peut être obtenue sous forme d’un N-uplet retourné par l’attribut ExceptionGroup.exceptions. Vous pouvez l’observer si vous capturez l’exception :

In [1]: try:
   ...:     raise ExceptionGroup("Message",
   ...:                          [ValueError('First Error'),
   ...:                           TypeError('Other Error'),
   ...:                           ValueError('Third Error')])
   ...: except ExceptionGroup as e:
   ...:     print(e.exceptions)
   ...: 
(ValueError('First Error'), TypeError('Other Error'), ValueError('Third Error'))

Il s’agit bien ici de capture d’une exception de type ExceptionGroup. Si vous essayez de capturer une ValueError ou une TypeError, vous n’obtiendrez rien. Ce n’est donc pas le meilleur moyen de gérer ce type d’exception.

Capturer des groupes d’exceptions avec except*

Python 3.11 a ajouté une nouvelle syntaxe, except*. Cette dernière permet de capturer toutes les exceptions d’un même type au sein d’un groupe d’exceptions.

In [2]: try:
   ...:     raise ExceptionGroup("Message",
   ...:                          [ValueError('First Error'),
   ...:                           TypeError('Other Error'),
   ...:                           ValueError('Third Error')])
   ...: except* ValueError as eg:
   ...:     print(eg.exceptions)
   ...: except* TypeError as eg:
   ...:     print(eg.exceptions)
   ...: 
(ValueError('First Error'), ValueError('Third Error'))
(TypeError('Other Error'),)

Nous pouvons remarquer deux choses dans le comportement de ce code.

En premier lieu, l’entrée dans une clause except* ne fait pas sortir de la structure. Les autres clauses sont également évaluées. Que se passerait-il avec une clause except classique ? Et bien ce n’est pas possible de mélanger les deux.

En second lieu, ce qui est capturé est également un ExceptionGroup qui ne contient que les exceptions du type capturé. Avec la clause except*, vous aurez toujours un ExceptionGroup, même lorsque une exception unitaire a été levée :

In [3]: try:
   ...:     raise ValueError("An error")
   ...: except* ValueError as eg:
   ...:     print(eg.exceptions)
   ...: 
(ValueError('An error'),)

Nous avons donc un comportement uniforme pour la gestion des exceptions. Ainsi, lorsque vous capturez de cette manière la ou les exceptions d’un certain type, vous pouvez systématiquement parcourir le N-uplet. Celui-ci ne contiendra les exception que de ce seul type.

Décomposition manuelle des ExceptionGroup

Une exception de type ExceptionGroup possède deux méthodes pour décomposer manuellement le N-Uplet : ExceptionGroup.subgroup(condition) et ExceptionGroup.split(condition).

Dans les deux cas, l’argument attendu est soit une fonction soit un type d’exception. Dans le premier cas, la fonction prends en paramètre un objet de type Exception et retourne True ou False. Dans le second cas, la méthode recherchera les exceptions du type indiqué en argument.

ExceptionGroup.subgroup(condition) retourne un objet de type ExceptionGroup contenant les exceptions correspondant à la condition. ExceptionGroup.split(condition) retourne un N-uplet de deux ExceptionGroup, le premier correspondant à la condition et le second contenant les autres exceptions.

Vous pouvez les explications et des exemples dans la documentation.

Enrichir les exceptions avec des notes

La version 3.11 permet également d’ajouter des notes aux exceptions. Pour cela, vous disposez d’une part d’une méthode Exception.add_note(str) et de l’attribut spécial Exception.__note__ qui contient la liste des notes. Mais je doute que vous utilisiez ce dernier, l’intérêt des notes est qu’elles apparaissent dans la stacktrace :

>>> try:
...     ve = ValueError('A Value Error')
...     ve.add_note("some note")
...     raise ve
... except ValueError as e:
...     e.add_note('some other note')
...     raise
... 
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError: A Value Error
some note
some other note

Notez que cet exemple est sous l’interpréteur Python, au moment de la rédaction de cet article, iPython ne les affichait pas.

Mais plus intéressant, ces notes apparaissent dans les logs si vous utilisez la méthode logging.exception() qui, en plus du message, log également la traceback.

In [4]: try:
   ...:     ve = ValueError('A Value Error')
   ...:     ve.add_note("Some note")
   ...:     raise ve
   ...: except ValueError as e:
   ...:     e.add_note('some other note')
   ...:     logging.exception('log message')
   ...: 
ERROR:root:log message
Traceback (most recent call last):
  File "<ipython-input-3-0fcb18c31704>", line 4, in <module>
    raise ve
ValueError: A Value Error
Some note
some other note

Honnêtement, je ne pense pas que vous utiliserez les notes des exceptions dans votre code. Comme on le voit dans la partie Motivation de la PEP 678, l’usage est destiné à des librairies ou outils. Néanmoins, il est intéressant de savoir comment elles seront générées.

En conclusion

Python 3.11 enrichit la manière de gérer les exceptions. Les ExceptionGroup et la clause except* offrent des possibilités intéressantes qui vont nécessiter un peu de prise en main pour trouver l’équilibre entre amélioration du code et complexité.

Si vous avez aimé ce post, n’hésitez pas à laisser un commentaire ci-dessous ou sur la page Facebook 😉

À propos de... Darko Stankovski

iT guy, photographe et papa 3.0, je vous fais partager mon expérience et découvertes dans ces domaines. Vous pouvez me suivre sur les liens ci-dessous.

Vous aimerez aussi...

Laisser un commentaire