[JOUR 22] II. Python, the right way : Gestion d'exceptions
Guide ultime pour gérer les exceptions en Python
Sommaire :
I. La propagation des exceptions
II. Séparation des responsabilités
III. Lever des exceptions
IV. Création d'exceptions personnalisées
V. Recommandations pour la gestion des exceptions
VI. Regroupons tout ensemble
Article précédent :
La gestion d’erreurs et d’exceptions est liée au concept de programmation défensive.
La programmation défensive est une approche de développement logiciel qui vise à créer un code robuste, fiable et sécurisé en anticipant et en gérant les erreurs potentielles, les exceptions et les scénarios imprévus. La programmation défensive recouvre plusieurs aspects, mais les plus importants dans notre contexte sont :
La détection et la gestion des erreurs : Utilisation de techniques telles que les blocs try-catch (try-except en Python) pour gérer les exceptions et éviter les arrêts du programme.
Principe du fail-fast : Détection et résolution des problèmes immédiatement, avant de poursuivre le reste du code.
Comportement prévisible : Assurer un comportement logiciel cohérent même dans des circonstances imprévues.
En Python, nous distinguons deux types d’erreurs : les erreurs de syntaxe et les exceptions.
Les erreurs de syntaxe sont détectées par le parser.
Les exceptions sont des erreurs rencontrées lors de l’exécution du programme.
I. La propagation des exceptions
Les exceptions se propagent de scope de fonction en scope de fonction jusqu'à atteindre la fonction principale qui a lancé le programme. Si nous ne gérons pas ces exceptions en amont, notre programme s'arrêtera, affichant l'exception levée ainsi que la stacktrace.
La stacktrace nous permet de retourner à l’endroit du code qui a causé l’exception.
Exemple de code produisant une exception :
def func_3():
# This function will generate an exception
raise ValueError("An error occurred in func_3")
def func_2():
# This function calls func_3
func_3()
def func_1():
# This function calls func_2
func_2()
def app():
try:
# This function calls func_1
func_1()
except Exception as e:
print("Exception handled in app:", e)
import traceback
traceback.print_exc()
# Call the app function to start the chain of calls
if __name__ == "__main__" :
app()
Et la potentielle stacktrace retournée :
Pour gérer les exceptions, nous utilisons des blocs try-except. En réalité, nous pouvons utiliser quatre mots-clés :
try:
except:
else:
finally:
try : C'est la partie où le code à surveiller est exécuté. Si tout se passe bien, on quitte le bloc; sinon, on passe aux suivants.
except : Si une erreur survient dans le bloc
try
,except
peut l’attraper et exécuter du code spécifique en fonction de l’erreur.
Nous pouvons gérer plusieurs types d'erreurs avec un bloc except :
try:
except (ValueError, KeyError) as error:
# Ce code est exécuté si nous rencontrons une ValueError ou une KeyError.
print(error)
Nous pouvons aussi indiquer plusieurs blocs except :
try:
except KeyError as error:
except TypeError as error:
else : En l’absence d’exception, nous pouvons exécuter du code supplémentaire dans le bloc
else
. Il est considéré comme une bonne pratique de placer le code non problématique dans la clauseelse
plutôt que dans la clausetry
.
try:
result = mon_dict["ma_clé"]
except KeyError as e:
print(e)
raise Exception("La clé demandée n'est pas présente")
else:
print(f"La clé existe bien, voici sa valeur : {result}")
return result
finally : Le code dans ce bloc s’exécutera toujours, quel que soit le résultat des blocs précédents. Il est courant d’utiliser
finally
pour faire le ménage, par exemple en fermant des connexions ouvertes, comme celle à une base de données relationnelle.
import sqlite3 # Exemple avec SQLite une base de donnée
try:
# Établir la connexion
conn = sqlite3.connect('ma_base.db')
# Exécuter des requêtes
# ...
except sqlite3.Error as e:
print(f"Une erreur est survenue : {e}")
finally:
# Fermer la connexion
if conn:
conn.close()
print("Connexion à la base de données fermée")
II. Séparation des responsabilités
Il est crucial de gérer les exceptions là où c’est pertinent, à la manière de ce que nous avons pu voir précédemment avec SOLID.
Considérons une fonction send_bill
contenant une fonction calculate_bill_per_person
:
def send_bill():
try:
bill = calculate_bill_per_person()
email_content = generate_email()
send_email(email_content)
except DivisionByZeroError:
# Gestion de l'erreur
except Exception as error:
# Gestion d'autres exceptions
def calculate_bill_per_person(x, y):
return x / y
Dans cet exemple, send_bill
gère une exception qui devrait être gérée par calculate_bill_per_person
. Il serait plus approprié de gérer cette erreur directement dans la fonction responsable du calcul :
def send_bill():
try:
bill = calculate_bill_per_person()
email_content = generate_email()
send_email(email_content)
except Exception as error:
# Gestion d'autres exceptions
def calculate_bill_per_person(x, y):
try:
return x / y
except DivisionByZeroError:
# Gestion spécifique de l'erreur
...
III. Lever des exceptions
Lever une exception avec raise
Le mot clé raise
permet de lever une exception :
montant = -1
if montant < 0:
raise Exception("Problème, un montant ne peut pas être < 0.")
Exceptions natives en Python
Pour une liste des exceptions natives en Python, consultez la documentation officielle : Python Exceptions.
IV. Création d'exceptions personnalisées
Pour créer vos propres exceptions, créez une nouvelle classe qui hérite de Exception
ou d'une de ses sous-classes :
class MonExceptionPersonnalisee(Exception):
pass
# Pour ajouter un message d'erreur personnalisé
class MonExceptionPersonnalisee(Exception):
def __init__(self, message):
self.message = message
super().__init__(self.message)
exemple de l’utilisation personnalisée :
def ma_fonction():
if condition:
raise MonExceptionPersonnalisee("Un message d'erreur personnalisé")
Bonnes pratiques avec les exceptions personnalisées
Hiérarchie d'exceptions : Pour les applications complexes, créez une hiérarchie d'exceptions avec une classe de base pour toutes vos exceptions personnalisées.
Nommage : Nommez vos exceptions avec des termes descriptifs se terminant par "Error" ou "Exception".
Informations contextuelles : Fournissez plus de contexte sur l'erreur en ajoutant des attributs à votre classe d'exception.
V. Recommandations pour la gestion des exceptions
Ne pas exposer les exceptions aux clients : Ne montrez jamais les détails techniques ou les stacktraces aux utilisateurs finaux. Utilisez des messages clairs et non techniques, tels que "Page not Found" ou "Resource not Found".
De plus exposer les détails d’implémentations de votre code peut aider des hackers à trouver des failles dans votre code.
Il faut donc différencier les exceptions qui ne seront retournées qu’à vous via les logs de l’application (plus sur ça dans le prochain article) et les exceptions que vous retournez aux clients.
Éviter les blocs try-except vides : Un bloc try-except vide, qui ne fait rien, est l'un des pires anti-pattern que vous puissiez rencontrer. Avec un tel bloc, le code continue de s'exécuter même si une erreur survient, sans que vous en soyez informé.
Inclure l’exception originale :
Lorsque vous remontez des exceptions, il est préférable d'inclure toujours l'erreur originale. Vous pouvez le faire facilement avec
raise ... from erreur_originale
pour être sûr d'inclure le message de base.try: result = mon_dict["ma_clé"] except KeyError as e: print(e) raise Exception("La clé demandée n'est pas présente") from e else: print(f"La clé existe bien, voici sa valeur : {result}") return result
Utiliser un logger pour enregistrer les exceptions :
Il est crucial d'enregistrer les exceptions qui surviennent.
Lorsqu'une application Python retourne des erreurs, vous pouvez les voir via le standard output du terminal, mais ce standard output ne permet pas d'accéder à tous les logs (les occurrences trop anciennes sont effacées), et une fois que vous fermez votre terminal, vous perdez tout. Dans un contexte professionnel, il est impraticable de gérer des applications de cette manière. La bonne manière de faire est d'utiliser un logger pour enregistrer les logs quelque part ( un fichier par exemple). Par exemple vous pouvez utilisez le module logging en Python :
import logging try: 1/0 except ZeroDivisionError: logging.exception("Erreur de division par zéro capturée")
Si vous voulez en savoir plus sur les loggers vous pouvez jeter un oeil à la documentation Python qui est très bien faite sur le sujet.
VI. Regroupons tout ensemble
Rédigeons un exemple avec tout ce que nous avons précédemment. Je reprends le cas d'une application envoyant des factures de restaurant par mail aux clients.
Nous allons séparer les erreurs affichées côté développeurs, qui pourront être consultées via le logger, et les messages retournés aux clients.
Par souci de simplicité, les messages retournés aux clients sont affichés avec un simple print. Pour de vraies applications, comme des web APIs, ces messages seraient retournés par HTTP.
from dataclasses import dataclass import logging @dataclass class TableData: table_id: int amount: int nb_persons: int # Fonction principale def send_bill(table_id: int, customer_name: str) -> None: try: table_data = get_table_data(table_id) bill = calculate_bill_per_customer(table_data.amount, table_data.nb_persons) email_content = generate_email(customer_name, bill) send_email(email_content) # Exceptions avec des messages destinés aux clients # Par simplicité, nous affichons juste le message avec un print. except CustomerTableNotFoundError as e: logging.exception(e) print("[Customer Message 1] Something wrong, please check table id.") except BillCalculateError as e: logging.exception(e) print("[Customer Message 2] Something went wrong, please ask support to check table data.") except EmailCreationError as e: logging.exception(e) print("[Customer Message 3] Something went wrong, please check if person is correct.") # Autres exceptions else: logging.info(f"Email sent to {customer_name}") def get_table_data(table_id: int) -> TableData: # Essaye de récupérer les infos depuis une base de données par exemple... # Ici nous utilisons une liste en dur pour simplifier l'exemple table_data_db = [ TableData(table_id=1, amount=100, nb_persons=4), TableData(table_id=2, amount=100, nb_persons=0), ] try: # Récupère le premier élément de la db avec le correct table_id table_data = next(obj for obj in table_data_db if obj.table_id == table_id) except StopIteration as e: logging.error(e) raise CustomerTableNotFoundError("Wrong table id") from e else: return table_data def calculate_bill_per_customer(amount: int, nb_persons: int) -> float: try: bill = amount / nb_persons except ZeroDivisionError as e: logging.error(e) raise BillCalculateError("Number of persons should be > 0") from e else: return bill def generate_email(customer_name: str, bill: float) -> str: return f"Hello! {customer_name}, you spent {bill}" def send_email(content: str) -> None: pass class MyAppException(Exception): def __init__(self, message: str): self.message = f"[MyAppException]: " + message super().__init__(self.message) class CustomerTableNotFoundError(MyAppException): pass class BillCalculateError(MyAppException): pass class EmailCreationError(MyAppException): pass if __name__ == "__main__": logging.basicConfig(level=logging.INFO) # Pour afficher également les logs de niveau INFO # send_bill(1, "Pedro") # Pas d'erreur, email envoyé # send_bill(2, "Pedro") # Côté dev : affiche BillCalculateError avec la traceback, côté client : affiche le message 2 send_bill(3, "Pedro") # Côté dev : affiche CustomerTableNotFoundError avec la traceback, côté client : affiche le message 1
Merci de votre lecture !