Skip to article frontmatterSkip to article content

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[1], 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[2], 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 ~/.local/lib/python3.12/site-packages/IPython/core/interactiveshell.py:343, in _modified_open(file, *args, **kwargs)
    336 if file in {0, 1, 2}:
    337     raise ValueError(
    338         f"IPython won't let you open fd={file} by default "
    339         "as it is likely to crash IPython. If you know what you are doing, "
    340         "you can use builtins' open."
    341     )
--> 343 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:

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:

# 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[4], line 9
      6     # 
      7     print("will never pass here")
----> 9 driver()

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

Cell In[3], 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[3], line 8, in time_bomb(n)
      6     return time_bomb(n-1)
      7 else:
----> 8     raise OverflowError("BOOM")

OverflowError: BOOM

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

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[10], line 3
      1 person = dict()
----> 3 set_age(person, '10')

Cell In[9], 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:

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

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

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... 

try .. finally

une instruction try peut avoir une clause finally

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[17], line 1
----> 1 finally_trumps_return(0)

Cell In[16], 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:

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:

class SplitError(Exception):
    pass

x, y = 1, 'a'

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

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")
INFO:root:in the code
ERROR:root:exception inattendue
Traceback (most recent call last):
  File "/tmp/ipykernel_2579/638466657.py", line 13, in <module>
    1/ 0
    ~^~~
ZeroDivisionError: division by zero