Retour sur les exceptions

Bloc except

Précédemment nous avons vu le bloc except qui, associé à un try, permet de traiter une exception qui surviendrait au cours de l’exécution.

def get_10th(seq):
    try:
        return seq[10]
    except IndexError:
        return None
>>> get_10th('abcdefghijkl')
'k'
>>> get_10th('abcd')

L’idée étant que le contenu du try peut lever une erreur qui sera attrapée par le bloc except si son type correspond (IndexError ici).

Plusieurs blocs except peuvent être placés à la suite sur des types d’erreurs différents pour leur offrir un traitement particulier.

def get_10th(seq):
    try:
        return seq[10]
    except IndexError:
        return None
    except KeyError:
        return None
>>> get_10th({5: 'a', 10: 'b'})
'b'
>>> get_10th({})

Mais on le voit ici, il peut arriver que le traitement soit le même pour différentes erreurs.
Dans ce cas, il est possible de spécifier les différents types d’exceptions au sein d’une même clause except, simplement en les plaçant dans un tuple.

def get_10th(seq):
    try:
        return seq[10]
    except (IndexError, KeyError):
        return None
Données complémentaires des exceptions

Une exception possède certes un type pour expliciter la cause de l’erreur, mais d’autres informations complémentaires sont aussi accessibles.

En effet, une exception n’est rien d’autre qu’un objet Python, qui possède donc des attributs et des méthodes. Pour récupérer l’objet de cette exception, il suffit de placer un as nom_de_la_variable derrière le except afin de l’affecter à une variable nom_de_la_variable.
Variable que l’on a tendance à appeler error / exception, ou plus simplement err, exc ou e.

>>> seq = []
>>> try:
...     seq[10]
... except IndexError as e:
...     print(e)
...
list index out of range

On voit qu’ici dans le cas d’une IndexError, l’exception contient un message nous expliquant la raison de l’erreur (l’index choisi est en dehors des bornes).
Ce message est un argument de l’exception, il est accessible via son attribut args.

>>> try:
...     seq[10]
... except IndexError as e:
...     print(e.args)
...     msg, = e.args
...     print(msg)
...
('list index out of range',)
list index out of range

Dans le cas d’une KeyError (clé invalide sur un dictionnaire), l’argument de l’erreur est simplement la clé.

>>> dic = {}
>>> try:
...     dic['abc']
... except KeyError as e:
...     print(e.args)
... 
('abc',)

Et c’est ce qui peut être un peu difficile avec le traitement des erreurs : chaque type d’exception présente des métadonnées qui lui sont propres, sans qu’il n’y ait forcément beaucoup de cohérence entre les types.

On notera que le as ... est aussi possible quand un tuple de types est précisé au except et s’utilise de la même manière.

def get_10th(seq):
    try:
        return seq[10]
    except (IndexError, KeyError) as e:
        return e.args
>>> get_10th([])
('list index out of range',)
>>> get_10th({})
(10,)

Autres mots-clés

Le mot-clé try ne s’accompagne pas uniquement de except. D’autres blocs sont aussi disponibles pour réagir à différents types de situations.

else

Par exemple, le bloc else permet de traiter le cas où tout s’est bien passé et qu’aucune exception n’a été levée (attrapée ou non).

def get_10th(seq):
    try:
        seq[10]
    except IndexError:
        print("erreur d'index")
    else:
        print("pas d'erreur")
>>> get_10th([])
erreur d'index
>>> get_10th({})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in get_10th
KeyError: 10
>>> get_10th([1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89])
pas d'erreur
>>> get_10th({10: 'foo'})
pas d'erreur

Cela est utile dans le cas d’une action qui dépendrait d’un traitement précédent, par exemple voici comment on pourrait implémenter la méthode pop des dictionnaires.
Pour rappel, cette méthode permet de supprimer une clé d’un dictionnaire et d’en renvoyer la valeur, et permet de renvoyer une valeur par défaut si la clé n’existe pas (ce que nous ferons par défaut dans notre implémentation).

def dict_pop(dic, key, default=None):
    try:
        value = dic[key]
    except KeyError:
        value = default
    else:
        del dic[key]
    return value
>>> dic = {'a': 42}
>>> dict_pop(dic, 'a')
42
>>> dic
{}
>>> dict_pop(dic, 'a')
>>> dict_pop(dic, 'a', 'pouet')
'pouet'
finally

Le bloc finally permet lui de réagir dans tous les cas, qu’une erreur soit survenue ou non, qu’elle ait été attrapée ou non.

def get_10th(seq):
    try:
        seq[10]
    except IndexError:
        print("erreur d'index")
    finally:
        print("traitement final")
>>> get_10th([])
erreur d'index
traitement final
>>> get_10th({})
traitement final
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in get_10th
KeyError: 10
>>> get_10th([1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89])
traitement final
>>> get_10th({10: 'foo'})
traitement final

On l’utilise par exemple pour la libération d’une ressource qui aurait été acquise avant le try1.

def read_int(path):
    f = open(path)
    try:
        return int(f.read())
    finally:
        print('Fermeture')
        f.close()

Par exemple avec les fichiers suivants :

salut
hello.txt
123
number.txt

On constate bien que l’appel à close se fait dans tous les cas, même si une erreur survient.

>>> read_int('number.txt')
Fermeture
123
>>> read_int('hello.txt')
Fermeture
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in read_int
ValueError: invalid literal for int() with base 10: 'salut\n'

On remarque aussi que le finally est exécuté même si un return est présent, il s’agit simplement d’un code exécuté à la toute fin de la fonction, mais qui n’en change pas la valeur de retour.

Attention, cela est bien sûr différent de placer un traitement à l’extérieur du bloc d’exception, qui lui ne sera pas exécuté en cas d’exception non attrapée.

def get_10th(seq):
    try:
        seq[10]
    except IndexError:
        print("erreur d'index")
    finally:
        print("traitement final")
    print("Fin de la fonction")
>>> get_10th([])
erreur d'index
traitement final
Fin de la fonction
>>> get_10th({})
traitement final
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in get_10th
KeyError: 10

  1. Même si l’on verra plus généralement un bloc with dans ce cas, qui permet de faire la même chose sans se prendre la tête.

Bloc with

Nous avons vu les gestionnaires de contexte (blocs with) plus tôt, quand nous apprenions à utiliser les fichiers. Permettant de gérer l’acquisition/libération de ressources, ils sont en fait une autre manière de traiter les exceptions en Python.

with open('hello.txt') as f:
    print(int(f.read()))

Dans le code précédent, même si la ligne 2 échoue (si le fichier ne contient pas un nombre), le fichier sera correctement fermé (Python appellera f.close() pour nous).
Car c’est ce que garantit le bloc with : assurer que le code de libération de la ressource sera toujours appelé1.

En cela, il s’apperente à un try / finally, puisqu’il s’agit d’exécuter une action pour acquérir la ressource (avant le try) puis pour la libérer (dans le finally).
Mais on n’a pas à faire d’appel explicite à f.close() pour fermer notre fichier, tout cela est fait de façon transparente.

Le bloc précédent est alors équivalent à :

f = open('hello.txt')
try:
    print(int(f.read())
finally:
    f.close()
Supprimer une exception

En plus de ça, le bloc with peut aussi influer sur la remontée d’exceptions, et donc stopper une exception qui serait levée à l’intérieur du bloc.

C’est ce que permet facilement le gestionnaire de contexte suppress du module contextlib de la bibliothèque standard.
Il s’utilise en précisant les types d’erreurs que l’on veut voir supprimés.

>>> from contextlib import suppress
>>> with suppress(ValueError):
...     print(int('abc'))
...

Plusieurs types peuvent être donnés en arguments pour tous les supprimer.

>>> with suppress(ValueError, TypeError):
...     print(1 + 'b')
...

Il ne permet pas de traitement plus avancé que ça, et se limite bien sûr à n’attraper que les erreurs des types spécifiés.

>>> with suppress(ValueError):
...     print(1 + 'b')
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

  1. Un gestionnaire de contexte se compose en fait d’une fonction pour initialiser la ressource et d’une autre pour la libérer, comme expliqué dans ce cours, qui nécessite des notions de programmation objet en Python.

Lever une exception

Les exceptions ont deux faces. D’un côté il s’agit de les attraper pour faire un traitement correct des erreurs, ce qui était l’objet des précédentes parties.
Mais de l’autre il est aussi question de lever des exceptions pour signaler les erreurs.

Souvenez-vous de notre factorielle qui ne gérait pas correctement les nombres négatifs en entrée, ce qui pouvait mener à des bugs1.

La factorielle d’un nombre négatif n’a pas de sens et notre fonction ne devrait même pas les accepter. Elle devrait lever une exception quand un tel nombre lui est donné, pour que l’appelant sache que la valeur passée est problématique.

Cela se fait avec le mot-clé raise. Celui-ci peut simplement être suivi du type de l’exception à lever. Il a pour effet de lever immédiatement l’exception voulue, et donc de couper tout traitement en cours.
L’exception remontera ensuite la pile d’exécution du programme jusqu’à être attrapée.

def factorielle(n):
    if n < 0:
        raise ValueError

    ret = 1
    for i in range(2, n + 1):
        ret *= i
    return ret

Nous utilisons ici une ValueError pour signaler qu’il s’agit d’un problème avec la valeur en elle-même. Lors de l’appel, nous obtenons bien une exception ValueError en cas de valeur invalide.

>>> factorielle(5)
120
>>> factorielle(-1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in factorielle
ValueError

Mais l’erreur n’est pas très explicite. Nous savons par le type d’erreur qu’il est question de la valeur, mais aucune autre information ne nous est donnée.
Parce que lors du raise nous avons simplement précisé un type sans plus d’informations.

Il est en fait possible d’appeler un type d’exception pour l’instancier, en lui donnant les arguments que l’on veut (généralement un message d’erreur), et d’utiliser cette instance pour le raise. Les arguments seront accessibles via l’attribut args de l’exception reçue comme nous l’avons vu précédemment, et affichés si l’erreur est imprimée à l’écran.

Ainsi, on peut modifier notre raise pour ajouter à l’exception un message d’erreur.

if n < 0:
    raise ValueError('Le nombre doit être positif')
>>> factorielle(-1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in factorielle
ValueError: Le nombre doit être positif

On peut aller encore plus loin et générer un message d’erreur précis en ajoutant d’autres informations.

if n < 0:
    raise ValueError(f'Le nombre doit être positif ({n} est négatif)')
>>> factorielle(-1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in factorielle
ValueError: Le nombre doit être positif (-1 est négatif)
Hiérarchie des exceptions

Nous avons rencontré plusieurs types d’exceptions pour coller à différentes situations. Il faut savoir que ces types sont hiérarchisés, afin de pouvoir traiter plus ou moins finement les erreurs qui surviennent.
Ainsi, faire un except sur un type d’exception arrêtera les exceptions de ce type mais aussi de tous les types qui en descendent.

Par exemple, toutes les exceptions que nous avons vues descendent d’un même type Exception : cela signifie qu’il suffit d’attraper Exception pour les attraper toutes.2

TypeError et ValueError sont alors deux des principales exceptions, la première indiquant une erreur dans le type des données et la seconde sur la valeur elle-même (le type correspond mais la valeur est incohérente). ValueError rassemble aussi des exceptions plus précises telles que UnicodeDecodeError et UnicodeEncodeError que nous avons déjà rencontrées.

IndexError et KeyError que l’on a beaucoup utilisées dans ce chapitre descendent d’une même exception LookupError qui attrape donc toutes les erreurs liées à la recherche dans un conteneur.

def get_10th(seq):
    try:
        return seq[10]
    except LookupError as e:
        print('erreur', e)
>>> get_10th([])
erreur list index out of range
>>> get_10th({})
erreur 10

On trouve aussi une grande famille d’erreurs sous OSError qui regroupe toutes les exceptions liées aux entrées/sorties, comme FileNotFoundError, FileExistsError ou PermissionError.

Voici un bref aperçu de cette hiérarchie :

Exception
 +-- TypeError
 +-- ValueError
 |    +-- UnicodeError
 |         +-- UnicodeDecodeError
 |         +-- UnicodeEncodeError
 +-- ArithmeticError
 |    +-- ZeroDivisionError
 +-- NameError
 |    +-- UnboundLocalError
 +-- LookupError
 |    +-- IndexError
 |    +-- KeyError
 +-- OSError
 |    +-- FileNotFoundError
 |    +-- FileExistsError
 |    +-- PermissionError
 +-- SyntaxError
 +-- AssertionError
 +-- RuntimeError
      +-- RecursionError

La hiérarchie complète des exceptions Python peut être trouvée à l’adresse suivante : https://docs.python.org/fr/3/library/exceptions.html#exception-hierarchy.


  1. Voir chapitre Boucler sur une condition (while).
  2. Allez dire ça au Professeur Chen, il sera vert.