Licence CC BY-NC-ND, Thierry Parmentelat & Arnaud Legout

from IPython.display import HTML
HTML(filename="_static/style.html")

exceptions#

c’est quoi ?#

il s’agit d’un mécanisme pour gérer les situations exceptionnelles, comme par exemple

# on ne peut pas diviser par 0

1 / 0
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[2], line 3
      1 # on ne peut pas diviser par 0
----> 3 1 / 0

ZeroDivisionError: division by zero
# on ne peut pas ouvrir un fichier qui n'existe pas

with open("will-not-open.txt") as f:
    pass
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[3], line 3
      1 # on ne peut pas ouvrir un fichier qui n'existe pas
----> 3 with open("will-not-open.txt") as f:
      4     pass

File ~/checkouts/readthedocs.org/user_builds/ue12-p24-python/envs/p24/lib/python3.12/site-packages/IPython/core/interactiveshell.py:324, in _modified_open(file, *args, **kwargs)
    317 if file in {0, 1, 2}:
    318     raise ValueError(
    319         f"IPython won't let you open fd={file} by default "
    320         "as it is likely to crash IPython. If you know what you are doing, "
    321         "you can use builtins' open."
    322     )
--> 324 return io_open(file, *args, **kwargs)

FileNotFoundError: [Errno 2] No such file or directory: 'will-not-open.txt'

pourquoi ?#

comme vous le voyez sur ces exemples, on ne peut pas vraiment gérer ces situation en retournant un code d’erreur:

  • à la rigueur dans le premier cas on pourrait imaginer renvoyer math.nan ou encore math.inf

  • mais comment gérer le deuxième exemple ?

idéalement, on veut un mécanisme où le souci peut être réglé, soit par la fonction elle-même, ou sinon par l’appelant de la fonction, ou par l’appelant de l’appelant, etc..
(un peu comme dans un workflow humain, pensez: une banque; si le souci ne peut pas être réglé par votre conseiller, on va en parler au chef d’agence, à son chef, etc..)

exception et pile d’exécution (1)#

dans ce genre de situations exceptionnelles, le langage va “lever une exception” (en anglais, lever = raise)
ça veut dire quoi ? voici ce qu’il va se passer:

dans le cas général on est dans une fonction qui a été appelée par une fonction qui a été appelée…
lorsqu’il y a exception, on commence par interrompre brutalement l’exécution

voyons pour commencer le cas où l’on n’a pas attrapé l’exception:

lancé depuis le terminal

ça n’a pas une grande importance, mais pour être bien clair, dans ce qui suit, on suppose qu’on lance depuis le terminal un programme foo.py qui lance une fonction main() qui appelle driver()
tout ça c’est juste pour simuler une certaine profondeur dans les appels de fonction..

# une fonction qui va faire raise
# mais pas tout de suite

def time_bomb(n):
    if n > 0:
        return time_bomb(n-1)
    else:
        raise OverflowError("BOOM")
# si on essaye de l'exécuter
# ça se passe mal

def driver():
    time_bomb(1)
    # 
    print("will never pass here")

driver()     
---------------------------------------------------------------------------
OverflowError                             Traceback (most recent call last)
Cell In[5], line 9
      6     # 
      7     print("will never pass here")
----> 9 driver()     

Cell In[5], line 5, in driver()
      4 def driver():
----> 5     time_bomb(1)
      6     # 
      7     print("will never pass here")

Cell In[4], line 6, in time_bomb(n)
      4 def time_bomb(n):
      5     if n > 0:
----> 6         return time_bomb(n-1)
      7     else:
      8         raise OverflowError("BOOM")

Cell In[4], line 8, in time_bomb(n)
      6     return time_bomb(n-1)
      7 else:
----> 8     raise OverflowError("BOOM")

OverflowError: BOOM
_images/except-stack-uncaught.svg

l’instruction try .. except#

comme vous pouvez le voir sur la première figure, on regarde dans la pile des appels, pour voir si à un moment donné on a attrapé l’exception; dans ce premier cas de figure, ça n’a pas été fait et le programme s’interrompt totalement (on retourne carrément dans le terminal !)

comment faire alors ?
c’est là qu’intervient l’instruction try .. except qui va nous permettre d’attraper l’exception

dans sa version la plus simple, elle se présente comme ceci:

# une instruction `try except` permet de capturer une exception

def divide(x, y):
    try:
        res  = x / y
        print(f"division OK {res=}")
    except ZeroDivisionError:
        print("zero divide !")
    print("continuing... ")
divide(8, 4)
division OK res=2.0
continuing... 
divide(8, 0)
zero divide !
continuing... 

exception et pile d’exécution (2)#

voyons maintenant la logique de l’exception dans le contexte d’appels, éventuellement profonds
si on attrape l’exception, notre premier exemple devient ceci:

def driver_try():
    try:
        time_bomb(2)
        print("not here")
    except Exception as exc:
        print(f"OOPS {type(exc)}, {exc}")
    # et on passera bien ici
    print("the show must go on")
    
driver_try()    
OOPS <class 'OverflowError'>, BOOM
the show must go on
_images/except-stack-try.svg

l’instruction raise#

pour compléter le tableau, on peut aussi signaler une condition exceptionnelle avec l’instruction raise

# je veux vérifier qu'on me passe bien ce que j'attends
# j'utilise l'exception prédéfinie 'ValueError', c'est exactement son propos

def set_age(person, age):
    if not isinstance(age, int):
        raise ValueError("a person's age must be an integer")
    person['age'] = age
person = dict()

set_age(person, '10')
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[11], line 3
      1 person = dict()
----> 3 set_age(person, '10')

Cell In[10], line 6, in set_age(person, age)
      4 def set_age(person, age):
      5     if not isinstance(age, int):
----> 6         raise ValueError("a person's age must be an integer")
      7     person['age'] = age

ValueError: a person's age must be an integer

hiérarchie des exceptions#

la clause raise doit fournir un objet idoine: ce doit être une instance de BaseException
par exemple on ne pourrait pas faire raise 1

la clause except#

et du coup on utilise cette hiérarchie pour n’attraper qu’une partie des exceptions possibles
dans sa forme la plus générale, elle ressemble à ceci

# les différentes formes de except
try:
    bloc
    de code
except ExceptionClass:        # les instances de
    bloc                      # ExceptionClass
    de rattrapage           
except (Class1, .. Classn):   # comme avec isinstance
    ...
except Class as instance:     # donne un nom à l'objet 
    ...                       # levé par raise
except:                       # attrape-tout - déconseillé
    ...

où on voit que:

  • on peut mettre plusieurs except après un try: chacune attrape une partie seulement des classes

  • la première qui convient est la bonne, et on retourne à un régime non exceptionnel

  • si aucune ne convient: l’exception se propage dans la pile - comme si on n’avait pas mis le try:

attrape-tout ?

le fait de capturer toutes les exceptions - avec except: ou except Exception:
est généralement considéré comme une mauvaise idée!

il vaut mieux comprendre ce que l’on capture
car sinon on risque de rendre silencieuses d’autres exceptions qui pourtant méritent d’être traitées

on développe ce sujet un peu plus bas, cherchez “traceback”

comment utiliser l’object exception#

l’object exception (celui qu’on a donné à raise) contient généralement des informations utiles à mieux comprendre ce qu’il se passe
c’est pourquoi except est généralement utilisée sous sa forme except .. as qui permet d’inspecter le contenu

selon le type de l’exception, on va trouver les détails dans des attributs, et toujours au moins l’attribut args

exemple de except .. as#

# imaginez que l'exception se produise au 4-éme appel dans la pile
# et que nous on n'a aucune idée du fichier qu'on est en train d'essayer d'ouvrir
try:
    with open("inexisting-filename") as f:
        ...
except IOError as exc:
    print(f"le type: {type(exc)}")
    print(f"l'exception elle-même {exc}")
    print(f"les arguments {exc.args}")
    # si on veut aller plus loin on peut faire un peu d'introspection
    # comme d'hab on va ignorer les attributs spéciaux
    attributes = [symbol for symbol in dir(exc) if not '__' in symbol]
    print(f"les attributs {attributes}")
    print(f"du coup {exc.filename=} et {exc.strerror=}")
le type: <class 'FileNotFoundError'>
l'exception elle-même [Errno 2] No such file or directory: 'inexisting-filename'
les arguments (2, 'No such file or directory')
les attributs ['add_note', 'args', 'characters_written', 'errno', 'filename', 'filename2', 'strerror', 'with_traceback']
du coup exc.filename='inexisting-filename' et exc.strerror='No such file or directory'

les exceptions sont efficaces#

voici une manière décente pour ouvrir un fichier; le code est beaucoup plus concis et efficace
que de tester si le fichier existe, si ça n’est pas un répertoire, si on a les droits d’écriture, etc.

try:
    with open('fichier-inexistant', 'r') as feed:
        for line in feed:
            print(line)
except OSError as err:
    print(err)
    print(err.args)
    print(err.filename)
[Errno 2] No such file or directory: 'fichier-inexistant'
(2, 'No such file or directory')
fichier-inexistant

le reste est pour les avancés

en première lecture vous pouvez vous arrêter ici, la suite couvre des aspects plutôt avancés de l’importation

notions avancées#

try .. else#

un peu comme avec for et while, on peut assortir le try d’une clause else
qui est exécutée uniquement s’il n’y a pas eu d’exception

xxx

une exception dans la clause else n’est pas capturée par les except précédents

def divide(x,y):
    try:
        res  = x / y
    except ZeroDivisionError:
        print('zero divide !')
    else:
        print('all right, result is', res)
    print('continuing... ')
divide(8, 3)
all right, result is 2.6666666666666665
continuing... 
divide(8, 0)
zero divide !
continuing... 

assez peu utilisé

en pratique toutefois, c’est peu utilisé
ici par exemple, on obtient exactement le même comportement si on écrit le print('continuing...') à la fin du bloc try:

def divide(x,y):
    try:
        res  = x / y
        print('all right, result is', res)
    except ZeroDivisionError:
        print('zero divide !')
    print('continuing... ')

try .. finally#

une instruction try peut avoir une clause finally

  • cette clause est toujours exécutée

    • si il n’y a aucune exception

    • si il y a une exception attrapée

    • si il y a une exception non attrapée

    • et même s’il y a un return dans le code !

  • elle sert à faire du nettoyage après l’exécution du bloc try

    • par exemple fermer un fichier

def finally_trumps_return(n):
    try:
        return 2 / n
    finally:
        print("finally is invicible !")
finally_trumps_return(0)
finally is invicible !
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[18], line 1
----> 1 finally_trumps_return(0)

Cell In[17], line 3, in finally_trumps_return(n)
      1 def finally_trumps_return(n):
      2     try:
----> 3         return 2 / n
      4     finally:
      5         print("finally is invicible !")

ZeroDivisionError: division by zero

instruction raise#

on n’a vu jusqu’ici la que sa forme usuelle raise instance
il existe aussi deux formes plus tarabiscotées:

  • raise tout court
    c’est la forme usuelle pour propager depuis un except l’exception originale, qui reste intacte

  • raise new_instance from original_exc pour propagation avec modification

exception personnalisée#

on peut bien souvent utiliser une des exceptions prédéfinies (comme ValueError ci-dessus)
mais parfois c’est intéressant de se définir ses propres exceptions

pour cela rien de plus simple:

  • dans la majorité des cas, on a uniquement besoin

    • d’un nom d’exception explicite finissant par Error

    • d’un message d’erreur

  • il suffit d’hériter de la classe Exception (ou une de ses sous-classes bien entendu)

    • par défaut, tous les arguments passés au constructeur
      sont mis dans un attribut args (un tuple)

class SplitError(Exception):
    pass

x, y = 1, 'a'

try:
    raise SplitError('split error', x, y)
except SplitError as exc:
    print(exc.args)

le module traceback#

en production, on devrait normalement s’astreindre à ne pas du tout utiliser d’attrape-tout

toutefois en développement, ce n’est pas évident de tout envisager du premier coup
aussi on trouve une forme assez répandue: attrape-tout avec instrumentation

import traceback

try:
    # un gros code; difficile de dire 
    # a priori toutes les exceptions
    # qui peuvent se produire
    pass
except OSError as exc:
    print(f"pour celle-ci je sais quoi faire {exc}")
except KeyboardInterrupt:
    print("pour celle-ci aussi")
except:
    # je suis tout près du main(), je ne veux pas laisser 
    # passer l'exception car ça se terminerait mal
    import traceback
    traceback.print_exc()
# la même chose avec le module logging
# en vrai on ne fait jamais print()
import logging

logging.basicConfig(level=logging.INFO)


try:
    # un gros code; difficile de dire 
    # a priori toutes les exceptions
    # qui peuvent se produire
    logging.info("in the code")
    1/ 0
except OSError as exc:
    logging.error(f"pour celle-ci je sais quoi faire {exc}")
except KeyboardInterrupt:
    logging.info("pour celle-ci aussi: bye")
except:
    # je suis tout près du main(), je ne veux pas laisser 
    # passer l'exception car ça se terminerait mal
    logging.exception("exception inattendue")