Gérer les exceptions (try/except)

Quand on code un programme il peut arriver que tout ne se passe pas comme prévu, que des exceptions surviennent qui interrompent le déroulé normal du programme.

Ce chapitre a pour but de vous présenter le fonctionnement des exceptions et la manière de les gérer.

Tout ne se passe pas comme prévu

On a déjà rencontré des exceptions, ce sont les erreurs qui se produisent quand une opération échoue (conversion impossible, élément inexistant dans un dictionnaire, ouverture d’un fichier introuvable, etc.). L’erreur survient alors sous la forme d’une exception avec un type particulier (ValueError, TypeError, KeyError, etc.).

Le souci est que cela coupe l’exécution de la fonction et du programme (hors interpréteur interactif).

Imaginons que nous souhaitions au chargement de notre jeu regarder si une sauvegarde existe. On essaierait alors d’ouvrir le fichier de sauvegarde, et s’il n’existe pas on obtiendrait une exception.

with open('game.sav') as save:
    state = load_game(save.read())

print('Jeu en cours...')
game.py

À l’exécution :

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'game.sav'

Ainsi, le programme s’arrête à l’exception, ce qui est plutôt embêtant. Notre jeu devrait être en mesure de démarrer sans sauvegarde existante, de traiter l’erreur et de continuer.

Pour autant une exception peut être un comportement attendu, d’autant plus si elle provient d’une valeur entrée par l’utilisateur. Dans une calculatrice, on ne veut pas que le programme plante si l’utilisateur demande une division par zéro. De même dans un annuaire si un nom n’est pas trouvé.

def calculatrice(a, op, b):
    if op == '+':
        return a + b
    if op == '-':
        return a - b
    if op == '*':
        return a * b
    if op == '/':
        return a / b
    print('Calcul impossible')
>>> calculatrice(3, '+', 0)
3
>>> calculatrice(3, '/', 0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 9, in calculatrice
ZeroDivisionError: division by zero

Comment alors peut-on gérer ces erreurs pour éviter cela ?

Éviter l'exception

Une solution pour éviter les erreurs est d’empêcher qu’elles se produisent. Ainsi, avant d’exécuter une action, on va tester différents cas d’erreurs pour les écarter. C’est la stratégie dite LBYL (Look before you leap, soit réfléchis avant d’agir).

Par exemple pour une calculatrice dans le cadre d’une division, on testerait si le quotient n’est pas nul avant de réaliser l’opération.

def calculatrice(a, op, b):
    if op == '+':
        return a + b
    if op == '-':
        return a - b
    if op == '*':
        return a * b
    if op == '/' and b != 0:
        return a / b
    print('Calcul impossible')
>>> calculatrice(3, '/', 2)
1.5
>>> calculatrice(3, '/', 0)
Calcul impossible

Pour notre problématique de sauvegarde, il faudrait donc être en mesure de tester si un fichier existe. Une telle fonctionnalité est disponible dans le module pathlib. Ce module propose un type Path représentant un chemin sur le système de fichiers, et bénéficiant naturellement d’une méthode exists renvoyant un booléen pour tester si le chemin existe ou non.

>>> from pathlib import Path
>>> p = Path('hello.txt')
>>> p.exists()
True
>>> p = Path('game.sav')
>>> p.exists()
False

Ainsi, on peut remplacer notre code de chargement par :

from pathlib import Path

if Path('game.sav').exists():
    with open('game.sav') as save:
        state = load_game(save.read())
else:
    state = None

On notera que les objets Path possèdent aussi une méthode open équivalente à la fonction du même nom : Path(foo).open() revient à écrire open(foo). On peut alors améliorer notre code précédent pour éviter les répétitions.

save_path = Path('game.sav')

if save_path.exists():
    with save_path.open() as save:
        state = load_game(save.read())
else:
    state = None
Limites

La stratégie LBYL est cependant limitée. Déjà, il est difficile d’envisager tous les cas d’erreurs : on pourrait obtenir une exception parce que le fichier est un répertoire, parce que les permissions ne sont pas suffisantes pour le lire, etc.

Mais considérons que l’on arrive à anticiper toutes les erreurs possibles, il resterait un problème. Quand on demande au système si un fichier existe, il le vérifie à l’instant t ; mais quand on l’ouvre nous sommes à l’instant t+1.
Pendant ce très court laps de temps le fichier a pu être supprimé, déplacé, ses permissions modifiées, et donc on n’échapperait pas à l’exception.

Il va alors nous falloir adopter une autre stratégie, dite EAFP (Easier to ask for forgiveness than permission, il est plus simple de demander pardon que demander la permission). C’est-à-dire laisser l’exception se produire et la traiter ensuite, comme nous allons le voir tout de suite.

Pour autant, la stratégie LBYL n’est pas à jeter, il reste des cas où elle est parfaitement adaptée, quand les conditions ne sont pas amenées à changer entre les pré-conditions et l’opération. C’est le cas par exemple du test pour le quotient nul dans la division, s’il est non-nul à l’instant t, il sera toujours à t+1.

Traiter l'exception

Pour gérer les exceptions on va utiliser un nouveau type de bloc, ou plutôt un couple de blocs, introduits par les mots-clés try et except (littéralement « essaie » et « à l’exception de »).

Ces deux mots-clés vont de pair pour intercepter les erreurs.
Dans le bloc try on place le code qui peut échouer, et le bloc except sera exécuté si et seulement si une exception survient. Il aura pour effet d’attraper cette exception et donc éviter que le programme ne plante, en proposant un traitement adapté.

>>> try:
...     result = 1 / 0
... except:
...     print('Division par zéro')
...
Division par zéro

Ici notre traitement est simplement d’afficher un message, mais il est possible de faire ce que l’on veut dans le bloc except, comme renvoyer une valeur particulière.

def division(a, b):
    try:
        return a / b
    except:
        return float('nan')

Quel est ce float('nan') ?

NaN, pour Not a Number (Pas un Nombre), est une valeur particulière de la norme des nombres flottants évoquant un résultat qui ne serait pas un nombre.
On y accède en Python via la variable nan du module math, ou avec un simple float('nan').

>>> division(3, 5)
0.6
>>> division(4, 2)
2.0
>>> division(10, 0)
nan

L’exécution du programme reprend normalement à l’issue du except. On ne le voit pas dans l’exemple car on y utilise un return, mais la suite de la fonction est bien exécutée.

def division(a, b):
    try:
        result = a / b
    except:
        result = float('nan')
    print('Résultat :', result)
    return result

Si l’exécution s’arrêtait juste après le except, nous ne passerions pas dans le print et le return.

>>> division(1, 2)
Résultat : 0.5
0.5
>>> division(1, 0)
Résultat : nan
nan

Aussi, nous utilisons except sans lui préciser aucun argument, il attrapera donc toute exception qui surviendrait, quel qu’en soit son type.

>>> division('x', 'y')
Résultat : nan
nan

Pourtant ce n’est pas toujours souhaitable. Par exemple dans le cas présent il s’agit d’une erreur de type et donc d’un mauvais usage de la fonction, on pourrait préférer ne pas traiter cette exception et la laisser survenir.
Ainsi, on pourra préciser derrière except le type de l’exception que l’on veut attraper, dans notre cas ZeroDivisionError.

def division(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return float('nan')

Notre fonction interceptera maintenant les erreurs de division par zéro, et uniquement celles-ci.

>>> division(1, 2)
0.5
>>> division(1, 0)
nan
>>> division(1, 'x')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in division
TypeError: unsupported operand type(s) for /: 'int' and 'str'
Attraper plusieurs exceptions

On peut placer plusieurs blocs except à la suite d’un try pour traiter des exceptions différentes. Sur le même principe que les if / elif / else, un seul de ces blocs sera exécuté, le premier qui correspond à l’exception survenue.

Changeons d’exemple et passons à un cas plus réel de lecture de fichier. Imaginons que l’on souhaite simplement lire un score dans un fichier. Il nous faut alors une fonction prenant un chemin de fichier en paramètre et renvoyant son contenu sous forme de nombre.

Plusieurs exceptions peuvent survenir comme on l’a vu : le fichier peut ne pas exister ou ne pas avoir les bonnes permissions (erreurs OSError), peut contenir une valeur invalide (ValueError) et d’autres encore.

def get_score(path):
    try:
        with open(path) as f:
            return int(f.read())
    except OSError:
        print("Impossible d'ouvrir le fichier")
    except ValueError:
        print('Score invalide')

Maintenant voilà ce que l’on obtient avec un fichier score.txt contenant 42 et un fichier hello.txt quelconque.

>>> get_score('score.txt')
42
>>> get_score('hello.txt')
Score invalide
>>> get_score('not_found.txt')
Impossible d'ouvrir le fichier

Bien sûr, les blocs except ne peuvent attraper que les exceptions qui surviendraient pendant l’exécution du try. Toute exception survenue avant leur échapperait.

def get_score(path):
    with open(path) as f:
        try:
            return int(f.read())
        except OSError:
            print("Impossible d'ouvrir le fichier")
        except ValueError:
            print('Score invalide')

Dans l’exemple précédent, la conversion du contenu du fichier en nombre a toujours lieu dans le try donc l’erreur sur hello.txt sera bien traitée. Mais l’ouverture du fichier se situe en dehors, nous ne gérons donc pas l’erreur OSError sur not_found.txt.

>>> get_score('score.txt')
42
>>> get_score('hello.txt')
Score invalide
>>> get_score('not_found.txt')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in get_score
FileNotFoundError: [Errno 2] No such file or directory: 'not_found.txt'

On voit que l’erreur qui survient est une FileNotFoundError et non une OSError. Il faut savoir qu’il existe une hiérarchie des exceptions que nous étudierons plus tard, et que FileNotFoundError est une erreur qui descend de OSError.

Aussi, l’exécution d’un bloc try s’arrête à la première erreur rencontrée. Cela signifie que tout son contenu n’est pas nécessairement exécuté, donc certaines variables définies dans le try n’existent peut-être pas.

Essayez la fonction suivante pour vous rendre compte des problèmes que cela peut poser.

def get_score(path):
    try:
        with open(path) as f:
            content = f.read()
            score = int(content)
    except OSError:
        print("Impossible d'ouvrir le fichier")
    except ValueError:
        print('Score invalide')
    return score

La fonction de cet exemple gère mal les exceptions : score ne sera jamais définie si une erreur est survenue, et donc le return échouera car accèdera à une variable inexistante.
Quant aux variables f et content on ne sait pas si elles existent car cela dépend de l’endroit précis où est survenue l’erreur. Pour une erreur à l’ouverture du fichier content ne sera pas définie, mais s’il s’agit d’une erreur lors de la conversion alors content contiendra sa bonne valeur.

Pour s’assurer que ces variables existent, il nous faut alors les définir dans tous les cas. Soit en le faisant avant le try (puisque c’est du code qui sera toujours exécuté), soit en répétant la définition dans chaque clause except.

def get_score(path):
    score = None

    try:
        with open(path) as f:
            content = f.read()
            score = int(content)
    except OSError:
        print("Impossible d'ouvrir le fichier")
    except ValueError:
        print('Score invalide')

    return score
Remontée d’erreurs

On peut voir l’exécution d’un programme informatique comme le parcours d’un arbre, de branche en branche, de façon à passer par toutes les feuilles. Les embranchements étant faits de conditions, de boucles et d’appels de fonctions. Notamment d’appels de fonctions.

À chaque instant du programme, l’instruction en cours d’exécution représente un curseur le long d’une branche : l’appel d’une fonction fait aller ce curseur plus loin dans l’arbre tandis qu’un retour le fait revenir sur ses pas.
Ainsi, il existe toujours un chemin depuis la racine du programme (le tronc) jusque la position actuelle du curseur.

Ce chemin représente la pile d’appels courante (stacktrace), les fonctions qu’il a fallu parcourir pour arriver jusqu’à ce point du programme. Toute exception est liée à la position courante dans le programme, au contexte qui l’a fait surgir, et donc à un certain état de la pile d’appels.

Cette pile liée à l’exception, on la voit d’ailleurs apparaître dans le terminal quand on n’attrape pas l’exception.

def division(a, b):
    return a / b

def inverse(x):
    return division(1, x)

def main():
    for i in range(10):
        print(i, inverse(i))

main()
error.py
% python error.py
Traceback (most recent call last):
  File "error.py", line 11, in <module>
    main()
  File "error.py", line 9, in main
    print(i, inverse(i))
  File "error.py", line 5, in inverse
    return division(1, x)
  File "error.py", line 2, in division
    return a / b
ZeroDivisionError: division by zero

De haut en bas, on voit que l’appel à main ligne 11 a provoqué un appel à inverse ligne 9, qui induit lui-même un appel à division ligne 5, à l’intérieur de laquelle se produit l’erreur (ligne 2).
Quand une exception n’est pas attrapée, elle remonte pas à pas la pile d’appels, et continue sa route jusqu’à couper le programme lui-même.

Car oui, il n’existe pas un seul endroit où l’exception peut être attrapée, elle peut l’être tout le long du programme. On pourrait choisir de placer un try / except dans la fonction division, mais aussi dans inverse ou dans main. Choisir de le mettre dans la boucle ou à l’extérieur, chaque solution ayant un comportement différent.

Par exemple, attraper l’exception à l’extérieur de la boucle aura pour effet de s’arrêter à la première erreur, puisque la boucle sera coupée à la première itération (i = 0).

def main():
    try:
        for i in range(10):
            print(i, inverse(i))
    except ZeroDivisionError:
        pass
error.py
% python error.py

Alors qu’attraper l’exception à l’intérieur de la boucle permettra de ne couper que l’itération courante puis de passer à la suivante.

def main():
    for i in range(10):
        try:
            print(i, inverse(i))
        except ZeroDivisionError:
            pass
error.py
% python error.py
1 1.0
2 0.5
3 0.3333333333333333
4 0.25
5 0.2
6 0.16666666666666666
7 0.14285714285714285
8 0.125
9 0.1111111111111111

Mais dans cet exemple, les appels à inverse(0) ou division(1, 0) continuent d’échouer : on pourrait choisir de traiter l’erreur dans ces fonctions pour renvoyer NaN.

def division(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return float('nan')
error.py
% python error.py
0 nan
1 1.0
2 0.5
3 0.3333333333333333
4 0.25
5 0.2
6 0.16666666666666666
7 0.14285714285714285
8 0.125
9 0.1111111111111111

Il convient alors chaque fois de réfléchir au comportement que l’on veut adopter et de placer judicieusement les blocs try / except en fonction de cela, pour n’être ni trop large, ni trop fin.