Aller au contenu

Chapitre 08 — Erreurs, exceptions et débogage

Durée estimée : 4 à 5 heures (cours + exercices + mini-projet)

Prérequis : Chapitre 07 — Fichiers, CSV et JSON (et tout ce qui précède : variables, conditions, boucles, structures de données, fonctions).

Objectifs — À la fin de ce chapitre, tu sauras : - lire un traceback de bas en haut et trouver en quelques secondes la ligne fautive ; - reconnaître les exceptions les plus courantes (ValueError, TypeError, KeyError, IndexError, FileNotFoundError, ZeroDivisionError…) et comprendre ce qu'elles signifient ; - intercepter les erreurs avec try / except / else / finally sans masquer les vrais bugs ; - lever tes propres erreurs avec raise et créer tes propres types d'exceptions ; - déboguer un programme avec print, breakpoint() et le débogueur de VS Code (points d'arrêt, pas à pas) ; - utiliser assert pour vérifier tes hypothèses pendant le développement.


Cours

1. Les erreurs ne sont pas tes ennemies

Quand tu débutes, voir un gros pavé rouge s'afficher dans le terminal fait peur. On a l'impression d'avoir « cassé » quelque chose. En réalité, un message d'erreur est un cadeau : Python t'explique précisément ce qui ne va pas, où, et dans quel contexte. Les développeurs expérimentés ne font pas moins d'erreurs que toi ; ils savent juste les lire vite.

Il existe deux grandes familles d'erreurs :

  1. Les erreurs de syntaxe (SyntaxError, IndentationError) : ton code n'est pas du Python valide. Python refuse de le lancer avant même d'exécuter la première ligne. C'est comme une lettre écrite dans une langue que le facteur ne comprend pas : il ne la distribue pas.
  2. Les exceptions : ton code est grammaticalement correct, mais quelque chose se passe mal pendant l'exécution (diviser par zéro, ouvrir un fichier qui n'existe pas, convertir "douze" en entier…). La lettre est bien écrite, mais l'adresse n'existe pas.
# Erreur de syntaxe : il manque les deux-points
if 3 > 2
    print("oui")
  File "/home/toi/cours/mon_script.py", line 2
    if 3 > 2
            ^
SyntaxError: expected ':'

Ici, rien ne s'exécute : Python a lu tout le fichier, vu l'erreur, et s'est arrêté. Les messages de syntaxe de Python 3.12+ sont très parlants : expected ':' veut dire « j'attendais deux-points ».

Ce chapitre parle surtout de la deuxième famille : les exceptions, qu'on peut anticiper, intercepter et même créer.

2. Lire un traceback (de bas en haut)

Prenons un vrai programme, enregistré dans mon_script.py :

def calculer_moyenne(notes):
    total = sum(notes)
    return total / len(notes)


def afficher_bilan(eleve, notes):
    moyenne = calculer_moyenne(notes)
    print(f"{eleve} : {moyenne:.2f}")


afficher_bilan("Alice", [12, 15, 9])
afficher_bilan("Bob", [])

Exécution :

Alice : 12.00
Traceback (most recent call last):
  File "/home/toi/cours/mon_script.py", line 12, in <module>
    afficher_bilan("Bob", [])
    ~~~~~~~~~~~~~~^^^^^^^^^^^
  File "/home/toi/cours/mon_script.py", line 7, in afficher_bilan
    moyenne = calculer_moyenne(notes)
  File "/home/toi/cours/mon_script.py", line 3, in calculer_moyenne
    return total / len(notes)
           ~~~~~~^~~~~~~~~~~~
ZeroDivisionError: division by zero

Le mot traceback signifie « retracer le chemin ». Python te montre la pile d'appels : la suite de fonctions qui s'appelaient les unes les autres au moment du drame. L'en-tête le dit clairement : most recent call last, « l'appel le plus récent en dernier ». Donc on lit de bas en haut :

  1. La dernière ligne est la plus importante : ZeroDivisionError: division by zero. Elle donne le type d'erreur (avant les deux-points) et un message (après). Ici : une division par zéro.
  2. Juste au-dessus, le dernier bloc File ... indique où l'erreur s'est produite : fichier mon_script.py, ligne 3, dans la fonction calculer_moyenne, sur l'instruction return total / len(notes). Les petits ~~~~^~~~ soulignent l'opération exacte qui a échoué : la division /.
  3. En remontant encore, tu vois qui a appelé cette fonction : afficher_bilan, ligne 7.
  4. Tout en haut, le point de départ : la ligne 12 du programme principal (<module> désigne le code qui n'est dans aucune fonction), avec l'appel afficher_bilan("Bob", []).

On reconstitue l'histoire : la ligne 12 appelle afficher_bilan avec une liste vide → qui appelle calculer_moyenne → qui divise par len([]), c'est-à-dire 0 → boum.

💡 Analogie : un traceback, c'est comme une enquête policière racontée à l'envers. La dernière ligne, c'est le crime. Les blocs au-dessus, ce sont les témoins, du plus proche au plus lointain. Le coupable n'est pas toujours là où le crime a eu lieu : ici, la division est « innocente », c'est la ligne 12 qui a envoyé une liste vide !

Méthode en 3 questions devant n'importe quel traceback :

Question Où regarder
Quoi ? Quel type d'erreur ? Dernière ligne, avant :
Où ? Quelle ligne de mon code ? Le bloc File le plus bas qui pointe vers ton fichier
Pourquoi ? D'où viennent les mauvaises valeurs ? Remonter les blocs File vers le haut

⚠️ Quand tu utilises une bibliothèque, certains blocs File pointent vers des fichiers que tu n'as pas écrits (par exemple .../lib/python3.12/json/decoder.py). Ignore-les dans un premier temps : cherche le dernier bloc qui mentionne ton fichier. Le bug est presque toujours là.

3. Le tour des exceptions courantes

Voici les exceptions que tu croiseras 95 % du temps. Chaque exemple a été exécuté pour de vrai ; seule la dernière ligne du traceback est montrée.

ValueError — bon type, mauvaise valeur

int("douze")
# ValueError: invalid literal for int() with base 10: 'douze'

float("3,5")   # la virgule française ne passe pas !
# ValueError: could not convert string to float: '3,5'

int() attend bien une chaîne, mais pas celle-là. Très fréquent avec input().

TypeError — mauvais type

"Âge : " + 30
# TypeError: can only concatenate str (not "int") to str

len(5)
# TypeError: object of type 'int' has no len()

Tu essaies de faire une opération qui n'a pas de sens pour ce type. Solution typique : convertir (str(30)) ou utiliser une f-string.

KeyError — clé absente d'un dictionnaire

prix = {"pomme": 1.2}
prix["poire"]
# KeyError: 'poire'

Le message affiche la clé fautive. Pense à prix.get("poire") ou au test if "poire" in prix.

IndexError — indice hors de la liste

valeurs = [10, 20, 30]
valeurs[3]
# IndexError: list index out of range

Une liste de 3 éléments a les indices 0, 1 et 2. L'erreur classique « décalée de un ».

FileNotFoundError — fichier introuvable

open("notes.txt")
# FileNotFoundError: [Errno 2] No such file or directory: 'notes.txt'

Soit le nom est faux, soit tu n'exécutes pas le script depuis le bon dossier (revois le chapitre 07 sur les chemins).

ZeroDivisionError — division par zéro

10 / 0
# ZeroDivisionError: division by zero

Vaut aussi pour // et %.

Deux autres très fréquentes

print(totl)          # faute de frappe dans le nom
# NameError: name 'totl' is not defined

"abc".push("d")      # cette méthode n'existe pas pour les str
# AttributeError: 'str' object has no attribute 'push'

La hiérarchie des exceptions

Les exceptions sont rangées en famille, comme un arbre généalogique. Voici la partie qui t'intéresse :

BaseException
 ├── KeyboardInterrupt          (Ctrl+C)
 ├── SystemExit                 (sys.exit())
 └── Exception                  ← mère de toutes les erreurs « normales »
      ├── ArithmeticError
      │    └── ZeroDivisionError
      ├── LookupError
      │    ├── IndexError
      │    └── KeyError
      ├── OSError
      │    └── FileNotFoundError
      ├── ValueError
      ├── TypeError
      ├── NameError
      └── AttributeError

Pourquoi c'est utile ? Parce qu'intercepter un parent intercepte aussi tous ses enfants. except LookupError attrape à la fois les KeyError et les IndexError. Et except Exception attrape presque tout… ce qui est souvent trop (on y revient dans les pièges).

Tu peux vérifier la parenté avec issubclass :

print(issubclass(KeyError, LookupError))          # True
print(issubclass(FileNotFoundError, OSError))     # True
print(issubclass(KeyboardInterrupt, Exception))   # False

4. Intercepter une exception : try / except

Jusqu'ici, une exception arrête net le programme. Avec try / except, tu dis à Python : « essaie ceci, et si telle erreur survient, fais plutôt cela ».

texte = input("Ton âge : ")
try:
    age = int(texte)
    print(f"Dans 10 ans, tu auras {age + 10} ans.")
except ValueError:
    print(f"« {texte} » n'est pas un nombre entier.")
print("Le programme continue.")

Avec l'entrée vingt :

Ton âge : vingt
« vingt » n'est pas un nombre entier.
Le programme continue.

Avec l'entrée 20 :

Ton âge : 20
Dans 10 ans, tu auras 30 ans.
Le programme continue.

Le déroulement :

  • Python exécute le bloc try ligne par ligne.
  • Si aucune erreur ne survient, le bloc except est ignoré.
  • Si une ValueError survient, Python abandonne le reste du bloc try (le print n'est pas exécuté) et saute dans except ValueError.
  • Si une autre erreur survient (par exemple une TypeError), elle n'est pas interceptée : le programme plante normalement, avec son traceback.

💡 Analogie : try / except, c'est le filet sous le trapéziste. Le trapéziste fait son numéro (try). S'il tombe d'une certaine façon prévue (except ValueError), le filet le rattrape. Mais un filet ne sert que pour les chutes prévues : si le chapiteau prend feu, le filet ne sert à rien.

Redemander jusqu'à obtenir une bonne valeur

Combiné avec une boucle while, c'est le schéma le plus utilisé du chapitre :

while True:
    try:
        quantite = int(input("Quantité : "))
        break                      # on ne sort que si la conversion a réussi
    except ValueError:
        print("Merci d'entrer un nombre entier.")

print(f"Tu as commandé {quantite} article(s).")

Avec les entrées abc, 2.5 puis 3 :

Quantité : abc
Merci d'entrer un nombre entier.
Quantité : 2.5
Merci d'entrer un nombre entier.
Quantité : 3
Tu as commandé 3 article(s).

Plusieurs except

Un même try peut avoir plusieurs blocs except, testés dans l'ordre :

def lire_element(valeurs, saisie):
    try:
        indice = int(saisie)
        return valeurs[indice]
    except ValueError:
        return "Il faut un nombre."
    except IndexError:
        return f"Indice entre 0 et {len(valeurs) - 1}, s'il te plaît."


fruits = ["pomme", "kiwi", "mangue"]
print(lire_element(fruits, "1"))     # kiwi
print(lire_element(fruits, "deux"))  # Il faut un nombre.
print(lire_element(fruits, "7"))     # Indice entre 0 et 2, s'il te plaît.

Si deux erreurs doivent être traitées de la même façon, regroupe-les dans un tuple (les parenthèses sont obligatoires) :

def diviser_textes(a, b):
    try:
        return int(a) / int(b)
    except (ValueError, ZeroDivisionError):
        return None


print(diviser_textes("10", "4"))   # 2.5
print(diviser_textes("10", "0"))   # None
print(diviser_textes("dix", "2"))  # None

⚠️ L'ordre compte : si tu mets except LookupError avant except KeyError, le second ne sera jamais atteint, car le parent attrape déjà l'enfant. Mets toujours les exceptions les plus précises en premier.

5. Récupérer l'objet erreur avec as

Tu peux donner un nom à l'exception attrapée pour afficher son message :

try:
    int("abc")
except ValueError as erreur:
    print("Type :", type(erreur).__name__)
    print("Message :", erreur)
Type : ValueError
Message : invalid literal for int() with base 10: 'abc'

C'est pratique pour informer l'utilisateur sans lui montrer un traceback complet :

from pathlib import Path

chemin = Path("fichier_qui_nexiste_pas.txt")
try:
    contenu = chemin.read_text(encoding="utf-8")
except FileNotFoundError as erreur:
    print(f"Impossible d'ouvrir le fichier : {erreur.filename}")
Impossible d'ouvrir le fichier : fichier_qui_nexiste_pas.txt

6. Les blocs else et finally

La structure complète ressemble à ceci :

try:
    ...   # le code « risqué »
except UneErreur:
    ...   # exécuté SI l'erreur survient
else:
    ...   # exécuté SI AUCUNE erreur n'est survenue
finally:
    ...   # exécuté TOUJOURS, quoi qu'il arrive

else : « tout s'est bien passé »

Pourquoi ne pas tout mettre dans le try ? Parce qu'on veut que le try ne contienne que la ligne susceptible d'échouer. Sinon, on risque d'attraper par erreur une exception venant d'une autre ligne.

import json
from pathlib import Path

chemin = Path("config.json")
chemin.write_text('{"theme": "sombre", "taille": 14}', encoding="utf-8")

try:
    texte = chemin.read_text(encoding="utf-8")
except FileNotFoundError:
    print("Pas de configuration, on utilise les valeurs par défaut.")
    config = {}
else:
    config = json.loads(texte)
    print("Configuration chargée.")

print(config)
Configuration chargée.
{'theme': 'sombre', 'taille': 14}

finally : « quoi qu'il arrive »

Le bloc finally s'exécute toujours : succès, erreur interceptée, erreur non interceptée, et même return dans le try. On s'en sert pour faire le ménage (fermer une connexion, afficher un message de fin…).

def diviser(a, b):
    try:
        resultat = a / b
    except ZeroDivisionError:
        print("Division par zéro évitée.")
        resultat = None
    else:
        print("Division réussie.")
    finally:
        print("--- fin du calcul ---")
    return resultat


print(diviser(10, 2))
print(diviser(1, 0))
Division réussie.
--- fin du calcul ---
5.0
Division par zéro évitée.
--- fin du calcul ---
None

💡 Pour les fichiers, with open(...) (vu au chapitre 07) fait déjà le ménage à ta place : il ferme le fichier même en cas d'erreur. C'est un finally intégré !

7. Lever une exception : raise

Jusqu'ici, on subissait les exceptions. Tu peux aussi les déclencher toi-même avec raise, pour signaler qu'une situation est anormale.

def calculer_moyenne(notes):
    if not notes:
        raise ValueError("Impossible de calculer la moyenne d'une liste vide.")
    return sum(notes) / len(notes)


print(calculer_moyenne([12, 15, 9]))
print(calculer_moyenne([]))
12.0
Traceback (most recent call last):
  File "/home/toi/cours/mon_script.py", line 8, in <module>
    print(calculer_moyenne([]))
          ~~~~~~~~~~~~~~~~^^^^
  File "/home/toi/cours/mon_script.py", line 3, in calculer_moyenne
    raise ValueError("Impossible de calculer la moyenne d'une liste vide.")
ValueError: Impossible de calculer la moyenne d'une liste vide.

Pourquoi lever une erreur plutôt que de renvoyer None ou d'afficher un message ? Parce qu'une fonction ne sait pas comment l'appelant veut réagir. Peut-être qu'il veut redemander une saisie, peut-être écrire dans un journal, peut-être arrêter. En levant une exception, la fonction dit « je ne peux pas faire mon travail, voici pourquoi » et laisse l'appelant décider :

def calculer_moyenne(notes):
    if not notes:
        raise ValueError("Impossible de calculer la moyenne d'une liste vide.")
    return sum(notes) / len(notes)


classe = {"Alice": [12, 15, 9], "Bob": [], "Chloé": [18, 16]}
for eleve, notes in classe.items():
    try:
        print(f"{eleve} : {calculer_moyenne(notes):.2f}")
    except ValueError as erreur:
        print(f"{eleve} : pas de moyenne ({erreur})")
Alice : 12.00
Bob : pas de moyenne (Impossible de calculer la moyenne d'une liste vide.)
Chloé : 17.00

Relancer une exception

Dans un except, un raise seul relance l'exception en cours. Utile pour noter qu'il y a eu un problème sans l'étouffer :

def convertir(texte):
    try:
        return int(texte)
    except ValueError:
        print(f"[journal] conversion ratée pour {texte!r}")
        raise    # on laisse remonter l'erreur d'origine


try:
    convertir("12a")
except ValueError:
    print("L'appelant a bien reçu l'erreur.")
[journal] conversion ratée pour '12a'
L'appelant a bien reçu l'erreur.

Tu peux aussi transformer une erreur en une autre plus parlante, en gardant la trace de l'originale avec from :

try:
    int("x")
except ValueError as erreur:
    raise RuntimeError("Lecture du fichier de scores impossible") from erreur

Le traceback affichera alors les deux erreurs, reliées par la phrase The above exception was the direct cause of the following exception.

8. Créer tes propres exceptions

Les exceptions intégrées sont génériques. Dans un vrai programme, il est souvent plus clair d'avoir des erreurs qui parlent ton langage métier : SoldeInsuffisantError, MotDePasseTropCourtError…

Pour cela, on utilise une classe. Les classes seront expliquées en détail au chapitre 10 ; pour l'instant, retiens simplement cette syntaxe à apprendre par cœur :

class SoldeInsuffisantError(Exception):
    pass

Lis-la comme : « je crée un nouveau type d'erreur, appelé SoldeInsuffisantError, qui est une sorte d'Exception ». Le pass signifie « rien d'autre à ajouter ». Par convention, le nom se termine par Error.

Tu l'utilises exactement comme une exception intégrée :

class SoldeInsuffisantError(Exception):
    pass


def retirer(solde, montant):
    if montant > solde:
        raise SoldeInsuffisantError(
            f"Solde insuffisant : {solde} € disponibles, {montant} € demandés"
        )
    return solde - montant


solde = 50
try:
    solde = retirer(solde, 80)
except SoldeInsuffisantError as erreur:
    print("Opération refusée.", erreur)
print(f"Solde final : {solde} €")
Opération refusée. Solde insuffisant : 50 € disponibles, 80 € demandés
Solde final : 50 €

Tu peux même créer ta propre petite famille : une erreur « mère » pour ton programme, et des erreurs « filles » plus précises. L'appelant pourra alors attraper toutes les erreurs de ton programme d'un coup avec la mère :

class BanqueError(Exception):
    pass


class SoldeInsuffisantError(BanqueError):
    pass


class MontantInvalideError(BanqueError):
    pass


def retirer(solde, montant):
    if montant <= 0:
        raise MontantInvalideError("Le montant doit être positif.")
    if montant > solde:
        raise SoldeInsuffisantError("Solde insuffisant.")
    return solde - montant


for montant in [20, -5, 500]:
    try:
        print("Nouveau solde :", retirer(100, montant))
    except BanqueError as erreur:
        print(f"{type(erreur).__name__} : {erreur}")
Nouveau solde : 80
MontantInvalideError : Le montant doit être positif.
SoldeInsuffisantError : Solde insuffisant.

9. Deux philosophies : vérifier avant, ou demander pardon après

Il y a deux façons de gérer un cas problématique :

  • LBYL (Look Before You Leap, « regarde avant de sauter ») : on teste avec un if avant d'agir.
  • EAFP (Easier to Ask Forgiveness than Permission, « plus facile de demander pardon que la permission ») : on agit, et on intercepte l'erreur si elle survient.
stock = {"pomme": 12, "kiwi": 0}

# Style LBYL
if "banane" in stock:
    print(stock["banane"])
else:
    print("Produit inconnu")

# Style EAFP
try:
    print(stock["banane"])
except KeyError:
    print("Produit inconnu")

Les deux sont valables. En Python, le style EAFP est très courant, surtout quand la vérification préalable est compliquée ou incomplète. Exemple : vérifier qu'une chaîne représente un entier valide ("-12", " 7 ", "+3"…) est pénible avec des if, alors que try: int(texte) règle la question en une ligne.

Règle pratique : utilise if pour les cas prévisibles et fréquents (un dictionnaire où la clé manque souvent → .get()), et try pour les cas exceptionnels ou difficiles à prévoir (saisie utilisateur, fichiers, réseau).

10. Déboguer : trouver les bugs qui ne plantent pas

Le pire bug n'est pas celui qui plante : c'est celui qui donne un résultat faux sans rien dire. Pas de traceback pour t'aider ! Il faut alors enquêter. Voici trois outils, du plus simple au plus puissant.

Prenons un programme bogué :

def appliquer_remise(prix, pourcentage):
    remise = prix * pourcentage // 100
    return prix - remise


panier = [19.99, 5.50, 12.00]
total = 0
for prix in panier:
    total = appliquer_remise(prix, 10)
print(f"Total après remise : {total:.2f} €")
Total après remise : 11.00 €

Le total devrait être environ 34,24 €. Quelque chose cloche… et même deux choses.

Outil n° 1 : le print-debugging

La méthode la plus ancienne et toujours efficace : ajouter des print pour voir les valeurs à chaque étape.

def appliquer_remise(prix, pourcentage):
    remise = prix * pourcentage // 100
    print(f"[DEBUG] prix={prix} remise={remise}")
    return prix - remise


panier = [19.99, 5.50, 12.00]
total = 0
for prix in panier:
    total = appliquer_remise(prix, 10)
    print(f"[DEBUG] total={total}")
print(f"Total après remise : {total:.2f} €")
[DEBUG] prix=19.99 remise=1.0
[DEBUG] total=18.99
[DEBUG] prix=5.5 remise=0.0
[DEBUG] total=5.5
[DEBUG] prix=12.0 remise=1.0
[DEBUG] total=11.0
Total après remise : 11.00 €

On voit immédiatement deux bugs :

  1. remise=1.0 pour 19,99 € au lieu de 1,999 : c'est // (division entière) au lieu de /.
  2. total ne s'accumule pas : il est remplacé à chaque tour (total = au lieu de total +=).

Astuce : la f-string avec = affiche le nom et la valeur, idéal pour déboguer :

prix = 19.99
pourcentage = 10
print(f"{prix=} {pourcentage=} {prix * pourcentage / 100=}")
prix=19.99 pourcentage=10 prix * pourcentage / 100=1.9989999999999997

(Le 1.9989999999999997 au lieu de 1.999 est normal : c'est l'imprécision des nombres décimaux en machine, vue au chapitre 01.)

⚠️ Pense à retirer tes print de débogage une fois le bug corrigé. Les préfixer par [DEBUG] permet de les retrouver facilement avec la recherche de VS Code (Ctrl+F).

Outil n° 2 : breakpoint() et le débogueur pdb

Ajouter des print partout devient vite lourd. Python possède un débogueur intégré : pdb. Il suffit d'écrire breakpoint() à l'endroit où tu veux mettre le programme en pause :

def appliquer_remise(prix, pourcentage):
    breakpoint()
    remise = prix * pourcentage / 100
    return prix - remise


total = appliquer_remise(80, 25)
print(total)

Quand tu lances le script, il s'arrête et te donne une invite (Pdb) où tu peux taper des commandes :

> /home/toi/cours/mon_script.py(2)appliquer_remise()
-> breakpoint()
(Pdb) p prix
80
(Pdb) p pourcentage
25
(Pdb) n
> /home/toi/cours/mon_script.py(3)appliquer_remise()
-> remise = prix * pourcentage / 100
(Pdb) p remise
*** NameError: name 'remise' is not defined
(Pdb) n
> /home/toi/cours/mon_script.py(4)appliquer_remise()
-> return prix - remise
(Pdb) p remise
20.0
(Pdb) c
60.0

La ligne marquée -> est la prochaine ligne qui va s'exécuter (pas encore exécutée !). C'est pour ça que remise n'existe pas encore au premier essai.

Les commandes essentielles :

Commande Signification
p variable print : affiche la valeur d'une expression
n next : exécute la ligne courante et passe à la suivante (sans entrer dans les fonctions appelées)
s step : entre dans la fonction appelée sur la ligne courante
l list : affiche le code autour de la ligne courante
c continue : reprend l'exécution normale jusqu'au prochain breakpoint()
q quit : arrête le programme

💡 N'oublie pas d'enlever breakpoint() avant de partager ton code, sinon il se mettra en pause chez tout le monde !

Outil n° 3 : le débogueur de VS Code

C'est le même principe que pdb, mais avec une interface graphique. C'est l'outil que tu utiliseras le plus au quotidien.

1. Poser un point d'arrêt. Dans VS Code, clique dans la marge à gauche des numéros de ligne : un point rouge apparaît. Le programme s'arrêtera juste avant d'exécuter cette ligne. (Raccourci : place le curseur sur la ligne et appuie sur F9.)

2. Lancer en mode débogage. Au lieu du bouton ▶ « Run », clique sur la petite flèche à côté et choisis « Python Debugger: Debug Python File », ou appuie sur F5 (choisis « Python File » si VS Code te demande une configuration). L'extension Python de Microsoft (installée au chapitre 00) inclut le débogueur.

3. Observer. Quand le programme s'arrête sur ton point rouge, la ligne est surlignée en jaune et le panneau de gauche affiche :

  • VARIABLES : toutes les variables locales et globales avec leur valeur actuelle. Tu peux déplier les listes et dictionnaires.
  • WATCH (Espion) : ajoute des expressions à surveiller en continu, par exemple total ou prix * 0.9.
  • CALL STACK (Pile des appels) : la même information qu'un traceback, en direct.

Tu peux aussi survoler une variable avec la souris dans le code pour voir sa valeur.

4. Avancer pas à pas. Une barre d'outils flottante apparaît en haut :

Bouton Raccourci Effet Équivalent pdb
▶ Continuer F5 Jusqu'au prochain point d'arrêt c
⤼ Pas à pas principal (Step Over) F10 Ligne suivante, sans entrer dans les fonctions n
⤓ Pas à pas détaillé (Step Into) F11 Entre dans la fonction appelée s
⤒ Pas à pas sortant (Step Out) Maj+F11 Termine la fonction courante et remonte r
⟳ Redémarrer Ctrl+Maj+F5 Relance depuis le début —
■ Arrêter Maj+F5 Arrête le programme q

5. Bonus : la console de débogage. Pendant la pause, l'onglet DEBUG CONSOLE permet de taper n'importe quelle expression Python (len(panier), prix * 2…) et de voir le résultat, dans le contexte exact du programme en pause.

6. Points d'arrêt conditionnels. Clic droit sur un point rouge → Edit Breakpoint → Expression, par exemple prix > 10. Le programme ne s'arrêtera que si la condition est vraie. Indispensable dans une boucle de 10 000 tours !

💡 Analogie : le débogueur, c'est la télécommande d'un film. Tu peux mettre sur pause (point d'arrêt), avancer image par image (F10), zoomer sur un détail (F11, Variables), ou reprendre la lecture (F5). Le print, c'est comme prendre des photos du film en espérant avoir capturé le bon moment.

11. assert : vérifier tes hypothèses

assert vérifie qu'une condition est vraie. Si elle est fausse, il lève une AssertionError avec le message fourni :

def calculer_remise(prix, pourcentage):
    assert 0 <= pourcentage <= 100, f"pourcentage invalide : {pourcentage}"
    return prix * pourcentage / 100


print(calculer_remise(50, 20))
print(calculer_remise(50, 150))
10.0
Traceback (most recent call last):
  File "/home/toi/cours/mon_script.py", line 7, in <module>
    print(calculer_remise(50, 150))
          ~~~~~~~~~~~~~~~^^^^^^^^^
  File "/home/toi/cours/mon_script.py", line 2, in calculer_remise
    assert 0 <= pourcentage <= 100, f"pourcentage invalide : {pourcentage}"
           ^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: pourcentage invalide : 150

assert sert à vérifier tes propres hypothèses de programmeur (« à cet endroit, cette liste ne peut pas être vide »), et à écrire de petits tests rapides :

def inverser(texte):
    return texte[::-1]


assert inverser("abc") == "cba"
assert inverser("") == ""
assert inverser("été") == "été"
print("Tous les tests passent.")
Tous les tests passent.

C'est d'ailleurs exactement ce qu'utilise l'outil de test pytest que tu découvriras au chapitre 13.

⚠️ Ne valide jamais une saisie utilisateur avec assert. Python peut être lancé avec l'option -O (optimisation) qui désactive tous les assert. Pour une donnée venant de l'extérieur (saisie, fichier), utilise if + raise ValueError(...).

Situation Outil
La donnée vient de l'utilisateur, d'un fichier, du réseau if ...: raise ValueError(...)
Tu vérifies que ton propre code est cohérent assert
Tu écris un test assert (puis pytest)

12. Une méthode de débogage en 5 étapes

Quand un bug te résiste, ne modifie pas le code au hasard. Procède comme un détective :

  1. Reproduire : trouve les entrées exactes qui provoquent le bug. Un bug qu'on ne sait pas reproduire ne se corrige pas.
  2. Lire : s'il y a un traceback, applique la méthode des 3 questions (quoi, où, pourquoi).
  3. Isoler : réduis le problème. Commente des morceaux, teste la fonction suspecte seule avec des valeurs simples.
  4. Observer : print, breakpoint() ou VS Code pour comparer ce que tu crois que contiennent tes variables avec ce qu'elles contiennent vraiment.
  5. Corriger puis vérifier : corrige une seule chose à la fois, relance, et ajoute un assert pour que le bug ne revienne pas.

💡 La méthode du canard en plastique : explique ton code ligne par ligne, à voix haute, à un canard en plastique (ou à un ami patient). Très souvent, tu trouves le bug toi-même en cours d'explication, parce que tu es obligé de dire ce que fait vraiment chaque ligne.


Pièges fréquents

Piège 1 — Le except nu qui avale tout

# ❌ À NE PAS FAIRE
def lire_age(texte):
    try:
        return int(texte)
    except:
        return 0


print(lire_age("25"))
print(lire_age(None))   # bug caché : on reçoit 0 au lieu d'une erreur claire

Un except: sans type attrape absolument tout : les fautes de frappe (NameError), les mauvais types (TypeError), et même Ctrl+C (KeyboardInterrupt) ! Ton programme ne plante plus… mais il produit des résultats faux en silence, et tu ne peux même plus l'interrompre proprement. C'est le piège n° 1 des débutants.

# ✅ Attrape uniquement ce que tu sais gérer
def lire_age(texte):
    try:
        return int(texte)
    except ValueError:
        return 0

Même chose pour except Exception: : à réserver au tout dernier filet de sécurité d'un programme, en affichant l'erreur, jamais pour l'étouffer.

Piège 2 — Oublier les parenthèses pour plusieurs exceptions

try:
    int("x")
except ValueError, TypeError:
    pass
    except ValueError, TypeError:
           ^^^^^^^^^^^^^^^^^^^^^
SyntaxError: multiple exception types must be parenthesized

✅ Correction : except (ValueError, TypeError):.

Piège 3 — Un try trop large

# ❌ Tout le bloc est dans le try
notes = {"Alice": 14, "Bob": 12}
try:
    nom = "Chloé"
    note = notes[nom]
    moyenne = sum(notes.values()) / len(note)   # bug : len(note) au lieu de len(notes)
    print(moyenne)
except Exception:
    print("Élève introuvable")
Élève introuvable

Le message est faux et trompeur : ici c'est bien le KeyError qui se déclenche, mais si Chloé existait, le bug len(note) (une TypeError) serait lui aussi transformé en « Élève introuvable ». Tu chercherais l'erreur au mauvais endroit pendant des heures.

# ✅ Le try ne contient que la ligne risquée, et l'exception précise
notes = {"Alice": 14, "Bob": 12}
nom = "Chloé"
try:
    note = notes[nom]
except KeyError:
    print(f"Élève introuvable : {nom}")
else:
    print(f"{nom} a {note}/20")

Piège 4 — Mettre le parent avant l'enfant

donnees = {"a": 1}
try:
    donnees["b"]
except LookupError:
    print("Problème de recherche")
except KeyError:
    print("Clé absente")   # ❌ jamais atteint
Problème de recherche

KeyError est une sorte de LookupError, donc le premier except gagne toujours. ✅ Mets les exceptions les plus précises en premier.

Piège 5 — Lever une simple chaîne avec raise

# ❌ raise avec une simple chaîne
raise "Erreur grave"
TypeError: exceptions must derive from BaseException

On ne peut lever que des objets exception. ✅ raise ValueError("Erreur grave").

Piège 6 — Valider des données avec assert

# ❌ disparaît si Python est lancé avec -O
def definir_age(age):
    assert age >= 0, "âge négatif"
    return age

✅ Pour une donnée extérieure :

def definir_age(age):
    if age < 0:
        raise ValueError(f"âge négatif : {age}")
    return age

Piège 7 — Oublier de convertir la saisie (et croire à un bug mystérieux)

age = input("Âge : ")
print(age + 1)
TypeError: can only concatenate str (not "int") to str

input() renvoie toujours une chaîne. Le message dit « on ne peut concaténer que des str à une str » : age est donc une chaîne. ✅ age = int(input("Âge : ")), protégé par un try / except ValueError.

Piège 8 — Afficher l'erreur puis continuer comme si de rien n'était

try:
    prix = float("douze")
except ValueError:
    print("Prix invalide")
print(prix * 2)
Prix invalide
Traceback (most recent call last):
  ...
NameError: name 'prix' is not defined

Après l'except, la variable prix n'a jamais été créée. ✅ Soit tu lui donnes une valeur par défaut dans l'except, soit tu arrêtes le traitement (return, continue, boucle de nouvelle saisie…).


Exercices

Exercice 1 ⭐ — Saisie d'un entier

Écris une fonction demander_entier(message) qui affiche message, lit la saisie de l'utilisateur et la renvoie convertie en int. Si la saisie n'est pas un entier, elle affiche Ce n'est pas un nombre entier, recommence. et redemande, autant de fois que nécessaire.

Ton âge : vingt
Ce n'est pas un nombre entier, recommence.
Ton âge : 20
Tu as 20 ans.

Exercice 2 ⭐ — Division sans plantage

Écris une fonction division_sure(a, b) qui renvoie a / b, ou la chaîne "Division par zéro impossible" si b vaut 0. Utilise try / except, pas de if.

print(division_sure(10, 4))   # 2.5
print(division_sure(3, 0))    # Division par zéro impossible

Exercice 3 ⭐ — Lire un traceback

Sans exécuter ce programme, réponds aux trois questions : quel est le type d'erreur ? Quelle ligne de code l'a déclenchée ? Quelle est la cause et comment la corriger ?

def prix_ttc(prix_ht):
    return prix_ht * 1.2


def afficher_facture(articles):
    for nom, prix in articles.items():
        print(f"{nom} : {prix_ttc(prix):.2f} €")


panier = {"stylo": 1.5, "cahier": "3.2", "gomme": 0.8}
afficher_facture(panier)
stylo : 1.80 €
Traceback (most recent call last):
  File "/home/toi/cours/facture.py", line 11, in <module>
    afficher_facture(panier)
    ~~~~~~~~~~~~~~~~^^^^^^^^
  File "/home/toi/cours/facture.py", line 7, in afficher_facture
    print(f"{nom} : {prix_ttc(prix):.2f} €")
                     ~~~~~~~~^^^^^^
  File "/home/toi/cours/facture.py", line 2, in prix_ttc
    return prix_ht * 1.2
           ~~~~~~~~^~~~~
TypeError: can't multiply sequence by non-int of type 'float'

Exercice 4 ⭐⭐ — Nettoyer une liste de notes

Écris une fonction convertir_notes(textes) qui reçoit une liste de chaînes et renvoie un tuple (notes_valides, rejets) :

  • notes_valides : la liste des notes converties en float ;
  • rejets : la liste des chaînes qui n'ont pas pu être converties ou dont la valeur n'est pas entre 0 et 20.

Bonus : accepte la virgule française ("12,5").

valides, rejets = convertir_notes(["12", "15.5", "abc", "12,5", "25", "", "-1", "8"])
print(valides)  # [12.0, 15.5, 12.5, 8.0]
print(rejets)   # ['abc', '25', '', '-1']

Exercice 5 ⭐⭐ — Chargement de configuration

Écris une fonction charger_config(chemin) qui lit un fichier JSON et renvoie le dictionnaire obtenu. Elle doit :

  • renvoyer {"langue": "fr", "volume": 50} (configuration par défaut) si le fichier n'existe pas, en affichant Fichier absent, configuration par défaut. ;
  • renvoyer la même configuration par défaut si le fichier n'est pas du JSON valide (l'exception s'appelle json.JSONDecodeError), en affichant Fichier corrompu, configuration par défaut. ;
  • dans les deux cas, ne jamais planter.

Exercice 6 ⭐⭐ — Inventaire et exceptions précises

On a un stock : stock = {"pomme": 10, "poire": 4, "kiwi": 0}. Écris une fonction retirer_du_stock(stock, produit, quantite) qui :

  • lève une KeyError (laisse-la se produire naturellement) si le produit n'existe pas ;
  • lève une ValueError("quantité invalide : ...") si la quantité est négative ou nulle ;
  • lève une ValueError("stock insuffisant pour ...") si la quantité demandée dépasse le stock ;
  • sinon, met à jour le stock.

Puis écris une boucle qui teste ces demandes et affiche un message pour chacune :

demandes = [("pomme", 3), ("banane", 1), ("poire", 10), ("kiwi", -2), ("poire", 4)]
OK : 3 pomme(s) retirée(s)
Erreur : produit inconnu 'banane'
Erreur : stock insuffisant pour poire (4 disponible(s))
Erreur : quantité invalide : -2
OK : 4 poire(s) retirée(s)
Stock final : {'pomme': 7, 'poire': 0, 'kiwi': 0}

Exercice 7 ⭐⭐⭐ — Ta propre exception

Crée une exception MotDePasseInvalideError. Écris une fonction verifier_mot_de_passe(mdp) qui lève cette exception avec un message précis si :

  • le mot de passe fait moins de 8 caractères ;
  • il ne contient aucun chiffre ;
  • il ne contient aucune majuscule.

Sinon, elle renvoie True. Écris ensuite un programme qui demande un mot de passe jusqu'à ce qu'il soit valide.

Mot de passe : abc
Refusé : au moins 8 caractères (actuellement 3)
Mot de passe : abcdefgh
Refusé : il faut au moins un chiffre
Mot de passe : abcdefg1
Refusé : il faut au moins une majuscule
Mot de passe : Abcdefg1
Mot de passe accepté !

Exercice 8 ⭐⭐⭐ — Chasse aux bugs

Ce programme est censé compter les mots d'un texte et afficher les 3 plus fréquents. Il contient trois bugs (dont un seul fait planter le programme). Trouve-les avec print, breakpoint() ou le débogueur de VS Code, et corrige-les. Pour chacun, note la technique qui t'a permis de le trouver.

def compter_mots(texte):
    compteur = {}
    for mot in texte.split():
        mot = mot.lower().strip(".,!?")
        if mot in compteur:
            compteur[mot] = 1
        else:
            compteur[mot] = 1
    return compteur


def top_mots(compteur, n):
    classement = sorted(compteur.items(), key=lambda paire: paire[1])
    return classement[:n]


texte = "Le chat dort. Le chien dort aussi. Le chat rêve !"
for mot, nombre in top_mots(compter_mots(texte), 3):
    print(f"{mot} : {nombre}")
print("Mot le plus fréquent :", top_mots(compter_mots(texte), 3)[3])

Résultat attendu après correction :

le : 3
chat : 2
dort : 2
Mot le plus fréquent : le

Mini-projet — Une calculatrice qui ne plante jamais

Cahier des charges

Tu vas écrire calculatrice.py, une calculatrice en ligne de commande impossible à faire planter, quoi que tape l'utilisateur.

  1. Le programme affiche une invite > et lit une expression de la forme nombre opérateur nombre, séparée par des espaces : 12 + 4, 7.5 * 2, 2 ** 10.
  2. Opérateurs acceptés : +, -, *, /, //, %, **.
  3. Les nombres peuvent être décimaux, avec un point ou une virgule (3,5).
  4. Le résultat est affiché ; s'il est entier (par exemple 4.0), on l'affiche sans décimale (4).
  5. Le mot-clé ans peut remplacer un nombre : il vaut le dernier résultat calculé (0 au départ).
  6. Commandes spéciales : historique (affiche tous les calculs réussis), aide, quitter.
  7. Toute erreur affiche un message clair commençant par Erreur : et le programme continue :
  8. format incorrect (pas 3 éléments) ;
  9. nombre invalide ;
  10. opérateur inconnu ;
  11. division par zéro ;
  12. résultat trop grand (10 ** 10000 en float → OverflowError).
  13. Ctrl+C et Ctrl+D (fin de saisie) quittent proprement avec Au revoir !, sans traceback.
  14. Le code définit une exception personnalisée ExpressionInvalideError pour les erreurs de format.

Exemple de session :

Calculatrice robuste — tape 'aide' pour l'aide, 'quitter' pour sortir.
> 12 + 4
= 16
> ans * 2,5
= 40
> 10 / 0
Erreur : division par zéro.
> 3 $ 4
Erreur : opérateur inconnu '$'.
> douze + 1
Erreur : 'douze' n'est pas un nombre.
> 5 +
Erreur : il faut 3 éléments séparés par des espaces (ex : 3 + 4).
> 2.0 ** 10000
Erreur : résultat trop grand.
> historique
1. 12 + 4 = 16
2. 16 * 2.5 = 40
> quitter
Au revoir !

Étapes suggérées

  1. Écris convertir_nombre(texte, dernier_resultat) : gère ans, remplace , par ., appelle float() et transforme la ValueError en ExpressionInvalideError avec un message clair.
  2. Écris calculer(a, operateur, b) : un dictionnaire ou un match qui associe chaque opérateur à son calcul. Lève ExpressionInvalideError si l'opérateur est inconnu. Laisse ZeroDivisionError et OverflowError se produire naturellement.
  3. Écris analyser(ligne, dernier_resultat) qui découpe la ligne, vérifie qu'il y a 3 morceaux et renvoie (a, operateur, b).
  4. Écris formater(nombre) qui affiche 16 plutôt que 16.0.
  5. Écris la boucle principale avec un seul try qui attrape chaque exception séparément, avec le bon message.
  6. Entoure le tout d'un try / except (KeyboardInterrupt, EOFError).
  7. Teste toutes les erreurs de la liste : c'est le cœur du projet !

Pistes d'amélioration

  • Accepter les expressions sans espaces (12+4) en analysant la chaîne caractère par caractère.
  • Ajouter des fonctions à un argument : racine 16, abs -3.
  • Sauvegarder l'historique dans un fichier JSON à la sortie et le recharger au démarrage (en gérant le fichier absent ou corrompu !).
  • Compter les erreurs par type et les afficher en quittant.

Corrigés

Corrigé de l'exercice 1
def demander_entier(message):
    while True:
        saisie = input(message)
        try:
            return int(saisie)   # return sort à la fois de la boucle et de la fonction
        except ValueError:
            print("Ce n'est pas un nombre entier, recommence.")


age = demander_entier("Ton âge : ")
print(f"Tu as {age} ans.")
Le `return` à l'intérieur du `try` n'est atteint que si `int(saisie)` a réussi. En cas d'échec, on passe dans l'`except`, puis la boucle `while True` recommence. Remarque qu'on n'a pas besoin de `break` : `return` suffit.
Corrigé de l'exercice 2
def division_sure(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return "Division par zéro impossible"


print(division_sure(10, 4))
print(division_sure(3, 0))
2.5
Division par zéro impossible
Dans un vrai programme, renvoyer tantôt un nombre, tantôt une chaîne n'est pas idéal (l'appelant doit vérifier le type). Une meilleure conception consiste souvent à laisser l'exception remonter, ou à renvoyer `None`.
Corrigé de l'exercice 3 1. **Quoi ?** Une `TypeError` : *can't multiply sequence by non-int of type 'float'*, « impossible de multiplier une séquence par un non-entier de type float ». Une « séquence » ici, c'est une chaîne. 2. **Où ?** Le bloc le plus bas : ligne 2, dans `prix_ttc`, sur `prix_ht * 1.2`. 3. **Pourquoi ?** En remontant : `prix_ttc` a été appelée ligne 7 avec `prix`, qui vient du dictionnaire `panier`. Le premier article (`stylo`) est passé, c'est donc un article suivant qui pose problème : `"cahier": "3.2"` est une **chaîne**, pas un nombre ! (Une chaîne multipliée par un entier fonctionne, `"ab" * 2` donne `"abab"`, mais pas par un `float`.) Correction : mettre `"cahier": 3.2` dans les données. Si les données viennent d'un fichier qu'on ne contrôle pas, on peut convertir dans `prix_ttc` :
def prix_ttc(prix_ht):
    return float(prix_ht) * 1.2


def afficher_facture(articles):
    for nom, prix in articles.items():
        print(f"{nom} : {prix_ttc(prix):.2f} €")


panier = {"stylo": 1.5, "cahier": "3.2", "gomme": 0.8}
afficher_facture(panier)
stylo : 1.80 €
cahier : 3.84 €
gomme : 0.96 €
Leçon : le crime a eu lieu ligne 2, mais le coupable était dans les **données** de la ligne 10.
Corrigé de l'exercice 4
def convertir_notes(textes):
    notes_valides = []
    rejets = []
    for texte in textes:
        try:
            note = float(texte.replace(",", "."))
        except ValueError:
            rejets.append(texte)
            continue                 # on passe directement à la chaîne suivante
        if 0 <= note <= 20:
            notes_valides.append(note)
        else:
            rejets.append(texte)
    return notes_valides, rejets


valides, rejets = convertir_notes(["12", "15.5", "abc", "12,5", "25", "", "-1", "8"])
print(valides)
print(rejets)
[12.0, 15.5, 12.5, 8.0]
['abc', '25', '', '-1']
Deux types de rejets : ceux qui ne sont pas des nombres (exception `ValueError`, y compris la chaîne vide), et ceux qui sont des nombres hors limites (simple `if`). Le `continue` évite d'utiliser une variable `note` qui n'existerait pas (voir le piège 8). On aurait aussi pu utiliser un bloc `else` sur le `try`.
Corrigé de l'exercice 5
import json
from pathlib import Path

CONFIG_PAR_DEFAUT = {"langue": "fr", "volume": 50}


def charger_config(chemin):
    chemin = Path(chemin)
    try:
        texte = chemin.read_text(encoding="utf-8")
        return json.loads(texte)
    except FileNotFoundError:
        print("Fichier absent, configuration par défaut.")
    except json.JSONDecodeError:
        print("Fichier corrompu, configuration par défaut.")
    return dict(CONFIG_PAR_DEFAUT)   # une copie, pour ne pas modifier l'original


# Tests
Path("bonne.json").write_text('{"langue": "en", "volume": 80}', encoding="utf-8")
Path("cassee.json").write_text('{"langue": "en", volume', encoding="utf-8")

print(charger_config("bonne.json"))
print(charger_config("absente.json"))
print(charger_config("cassee.json"))
{'langue': 'en', 'volume': 80}
Fichier absent, configuration par défaut.
{'langue': 'fr', 'volume': 50}
Fichier corrompu, configuration par défaut.
{'langue': 'fr', 'volume': 50}
On renvoie une **copie** (`dict(...)`) de la configuration par défaut : si l'appelant modifie le dictionnaire reçu, la constante globale reste intacte. `json.JSONDecodeError` est d'ailleurs une sous-classe de `ValueError`.
Corrigé de l'exercice 6
def retirer_du_stock(stock, produit, quantite):
    disponible = stock[produit]           # KeyError si le produit n'existe pas
    if quantite <= 0:
        raise ValueError(f"quantité invalide : {quantite}")
    if quantite > disponible:
        raise ValueError(f"stock insuffisant pour {produit} ({disponible} disponible(s))")
    stock[produit] = disponible - quantite


stock = {"pomme": 10, "poire": 4, "kiwi": 0}
demandes = [("pomme", 3), ("banane", 1), ("poire", 10), ("kiwi", -2), ("poire", 4)]

for produit, quantite in demandes:
    try:
        retirer_du_stock(stock, produit, quantite)
    except KeyError:
        print(f"Erreur : produit inconnu {produit!r}")
    except ValueError as erreur:
        print(f"Erreur : {erreur}")
    else:
        print(f"OK : {quantite} {produit}(s) retirée(s)")

print("Stock final :", stock)
OK : 3 pomme(s) retirée(s)
Erreur : produit inconnu 'banane'
Erreur : stock insuffisant pour poire (4 disponible(s))
Erreur : quantité invalide : -2
OK : 4 poire(s) retirée(s)
Stock final : {'pomme': 7, 'poire': 0, 'kiwi': 0}
La fonction ne fait **aucun** `print` : elle signale les problèmes par des exceptions, et c'est la boucle (l'appelant) qui décide comment les présenter. Le bloc `else` n'affiche « OK » que si aucune exception n'a eu lieu. `{produit!r}` affiche la chaîne avec ses guillemets.
Corrigé de l'exercice 7
class MotDePasseInvalideError(Exception):
    pass


def verifier_mot_de_passe(mdp):
    if len(mdp) < 8:
        raise MotDePasseInvalideError(
            f"au moins 8 caractères (actuellement {len(mdp)})"
        )
    if not any(caractere.isdigit() for caractere in mdp):
        raise MotDePasseInvalideError("il faut au moins un chiffre")
    if not any(caractere.isupper() for caractere in mdp):
        raise MotDePasseInvalideError("il faut au moins une majuscule")
    return True


while True:
    mdp = input("Mot de passe : ")
    try:
        verifier_mot_de_passe(mdp)
    except MotDePasseInvalideError as erreur:
        print(f"Refusé : {erreur}")
    else:
        print("Mot de passe accepté !")
        break
Avec les entrées `abc`, `abcdefgh`, `abcdefg1`, `Abcdefg1` :
Mot de passe : abc
Refusé : au moins 8 caractères (actuellement 3)
Mot de passe : abcdefgh
Refusé : il faut au moins un chiffre
Mot de passe : abcdefg1
Refusé : il faut au moins une majuscule
Mot de passe : Abcdefg1
Mot de passe accepté !
`any(...)` renvoie `True` si au moins un élément est vrai (on a vu ce genre d'expression avec les compréhensions au [chapitre 06](06-chaines-et-comprehensions.md)). Une version sans `any` utiliserait une boucle `for` avec une variable booléenne.
Corrigé de l'exercice 8 Les trois bugs : 1. **Plantage** : `top_mots(...)[3]` provoque `IndexError: list index out of range`, car la liste du top 3 a les indices 0, 1, 2. De plus, on veut le **mot** (premier élément de la paire), donc `[0][0]`. *Trouvé grâce au traceback.* 2. **Le compteur ne compte pas** : dans `compter_mots`, les deux branches font `= 1`. Il faut `compteur[mot] += 1` dans la première. *Trouvé avec un `print(compteur)` ou en observant `compteur` dans le panneau VARIABLES : tous les mots valaient 1.* 3. **Tri à l'envers** : `sorted` trie par ordre croissant, donc on obtient les mots les **moins** fréquents. Il faut `reverse=True`. *Trouvé en affichant `classement`.*
def compter_mots(texte):
    compteur = {}
    for mot in texte.split():
        mot = mot.lower().strip(".,!?")
        if mot in compteur:
            compteur[mot] += 1              # bug 2 corrigé
        else:
            compteur[mot] = 1
    return compteur


def top_mots(compteur, n):
    classement = sorted(compteur.items(), key=lambda paire: paire[1], reverse=True)  # bug 3
    return classement[:n]


texte = "Le chat dort. Le chien dort aussi. Le chat rêve !"
for mot, nombre in top_mots(compter_mots(texte), 3):
    print(f"{mot} : {nombre}")
print("Mot le plus fréquent :", top_mots(compter_mots(texte), 3)[0][0])   # bug 1
le : 3
chat : 2
dort : 2
Mot le plus fréquent : le
Attention à un détail : le mot `!` isolé à la fin devient la chaîne vide `""` après `strip`. Il n'apparaît pas dans le top 3 ici, mais une version plus propre ignorerait les mots vides avec `if not mot: continue`. Remarque aussi que `sorted` est **stable** : à égalité (chat et dort valent 2), l'ordre d'apparition est conservé.
Corrigé du mini-projet
"""Calculatrice en ligne de commande qui ne plante jamais."""


class ExpressionInvalideError(Exception):
    pass


OPERATEURS = ["+", "-", "*", "/", "//", "%", "**"]

AIDE = """Saisis : nombre opérateur nombre (séparés par des espaces).
Opérateurs : + - * / // % **
'ans' = dernier résultat. Commandes : historique, aide, quitter."""


def convertir_nombre(texte, dernier_resultat):
    """Convertit un texte en float. Accepte 'ans' et la virgule décimale."""
    if texte.lower() == "ans":
        return dernier_resultat
    try:
        return float(texte.replace(",", "."))
    except ValueError:
        raise ExpressionInvalideError(f"{texte!r} n'est pas un nombre.") from None


def calculer(a, operateur, b):
    """Effectue le calcul. Peut lever ZeroDivisionError ou OverflowError."""
    match operateur:
        case "+":
            return a + b
        case "-":
            return a - b
        case "*":
            return a * b
        case "/":
            return a / b
        case "//":
            return a // b
        case "%":
            return a % b
        case "**":
            return a**b
        case _:
            raise ExpressionInvalideError(f"opérateur inconnu {operateur!r}.")


def analyser(ligne, dernier_resultat):
    """Découpe la ligne en (a, operateur, b)."""
    morceaux = ligne.split()
    if len(morceaux) != 3:
        raise ExpressionInvalideError(
            "il faut 3 éléments séparés par des espaces (ex : 3 + 4)."
        )
    texte_a, operateur, texte_b = morceaux
    a = convertir_nombre(texte_a, dernier_resultat)
    b = convertir_nombre(texte_b, dernier_resultat)
    return a, operateur, b


def formater(nombre):
    """Affiche 16 plutôt que 16.0, et arrondit l'affichage des décimaux."""
    if isinstance(nombre, complex):
        return str(nombre)
    if nombre.is_integer():
        return str(int(nombre))
    return f"{nombre:.10g}"


def boucle_principale():
    historique = []
    dernier_resultat = 0.0
    print("Calculatrice robuste — tape 'aide' pour l'aide, 'quitter' pour sortir.")
    while True:
        ligne = input("> ").strip()
        commande = ligne.lower()
        if commande == "":
            continue
        if commande == "quitter":
            break
        if commande == "aide":
            print(AIDE)
            continue
        if commande == "historique":
            if not historique:
                print("(historique vide)")
            for numero, calcul in enumerate(historique, start=1):
                print(f"{numero}. {calcul}")
            continue

        try:
            a, operateur, b = analyser(ligne, dernier_resultat)
            resultat = calculer(a, operateur, b)
        except ExpressionInvalideError as erreur:
            print(f"Erreur : {erreur}")
        except ZeroDivisionError:
            print("Erreur : division par zéro.")
        except OverflowError:
            print("Erreur : résultat trop grand.")
        else:
            dernier_resultat = resultat
            texte = f"{formater(a)} {operateur} {formater(b)} = {formater(resultat)}"
            historique.append(texte)
            print(f"= {formater(resultat)}")


def main():
    try:
        boucle_principale()
    except (KeyboardInterrupt, EOFError):
        print()   # passe à la ligne après le ^C ou ^D
    print("Au revoir !")


main()
Session de test (les saisies sont après `> `) :
Calculatrice robuste — tape 'aide' pour l'aide, 'quitter' pour sortir.
> 12 + 4
= 16
> ans * 2,5
= 40
> 10 / 0
Erreur : division par zéro.
> 3 $ 4
Erreur : opérateur inconnu '$'.
> douze + 1
Erreur : 'douze' n'est pas un nombre.
> 5 +
Erreur : il faut 3 éléments séparés par des espaces (ex : 3 + 4).
> 2.0 ** 10000
Erreur : résultat trop grand.
> 7 // 2
= 3
> 0.1 + 0.2
= 0.3
> historique
1. 12 + 4 = 16
2. 16 * 2.5 = 40
3. 7 // 2 = 3
4. 0.1 + 0.2 = 0.3
> quitter
Au revoir !
Points importants : - **Une seule exception personnalisée** pour toutes les erreurs de saisie : la boucle principale n'a pas besoin de savoir *quel* contrôle a échoué, le message le dit. - `raise ... from None` dans `convertir_nombre` masque l'exception d'origine (`ValueError`) dans un éventuel traceback : on la remplace volontairement par la nôtre. - Le `try` principal ne contient que les deux appels risqués ; la mise à jour de l'historique est dans le `else`. - `-8 ** 0.5` (la puissance d'un nombre négatif par un décimal) donne un nombre **complexe** en Python (`(1.7319121124709868e-16+2.8284271247461903j)`) : `formater` le gère avec `isinstance` au lieu de planter sur `.is_integer()`. Ce genre de cas limite, on le découvre… en essayant de faire planter son programme ! - `f"{nombre:.10g}"` affiche au plus 10 chiffres significatifs : `0.1 + 0.2` s'affiche `0.3` au lieu de `0.30000000000000004`. - Ctrl+C (`KeyboardInterrupt`) et Ctrl+D / fin de fichier (`EOFError`) sont attrapés **une seule fois**, autour de toute la boucle.

Récapitulatif

  • Une erreur de syntaxe empêche le programme de démarrer ; une exception survient pendant l'exécution.
  • Un traceback se lit de bas en haut : dernière ligne = quoi ; dernier bloc File de ton fichier = où ; blocs au-dessus = comment on en est arrivé là.
  • Exceptions courantes : ValueError (mauvaise valeur), TypeError (mauvais type), KeyError (clé absente), IndexError (indice hors limites), FileNotFoundError (fichier introuvable), ZeroDivisionError, NameError, AttributeError. Elles forment une hiérarchie : attraper un parent attrape ses enfants.
  • try / except Type intercepte une erreur précise ; as erreur récupère l'objet et son message ; else s'exécute si tout s'est bien passé ; finally s'exécute toujours.
  • Jamais de except: nu ; garde le bloc try le plus court possible ; exceptions précises d'abord.
  • raise ValueError("message") signale un problème et laisse l'appelant décider ; raise seul relance ; raise ... from ... chaîne les erreurs.
  • Créer une exception : class MonErreur(Exception): pass (les classes seront expliquées au chapitre 10).
  • Déboguer : print(f"{variable=}"), breakpoint() (commandes p, n, s, l, c, q), débogueur VS Code (point d'arrêt F9, lancement F5, pas à pas F10 / F11, panneaux Variables / Watch / Call Stack).
  • assert condition, "message" vérifie tes hypothèses de programmeur et sert aux tests, pas à valider des saisies.

Checklist de validation

  • Je sais trouver en moins de 30 secondes le type d'erreur et la ligne fautive dans un traceback.
  • Je sais citer et expliquer au moins six exceptions courantes.
  • Je sais écrire une boucle qui redemande une saisie jusqu'à ce qu'elle soit valide.
  • Je sais à quoi servent else et finally dans un try.
  • Je sais lever une exception avec raise et créer ma propre exception.
  • J'ai posé un point d'arrêt dans VS Code et avancé pas à pas en observant les variables.
  • Je sais pourquoi except: tout seul est dangereux et pourquoi assert ne doit pas valider une saisie.
  • Ma calculatrice résiste à toutes les saisies que j'ai pu imaginer, y compris Ctrl+C.

← Chapitre précédent | Sommaire | Chapitre suivant →