Solutions — série 05-3 (exceptions : try/except/else/finally et raise)
Rappel de la règle d'or : on n'ouvre ce fichier qu'après avoir terminé (ou sérieusement séché sur) la série. Pour cette série, compare surtout les DÉCISIONS : qu'as-tu attrapé, où, et qu'as-tu laissé planter ? Et vérification non négociable : zéro
except:nu dans tes fichiers.
Exercice facile a — la saisie robuste
Raisonnement — Le motif canonique de la leçon (exemple 2) : boucle infinie, tentative de conversion dans le try, break juste après la conversion réussie, message et nouveau tour sinon. Le try n'entoure QUE la ligne risquée.
Solution
# ex-05-3-a.py
while True:
try:
number = int(input("Un nombre entier : "))
break # only reached if int() succeeded
except ValueError:
print("Entier attendu, réessaie.")
print(f"Son carré vaut {number ** 2}")
Session (vérifiée) :
Un nombre entier : douze
Entier attendu, réessaie.
Un nombre entier : 3.5
Entier attendu, réessaie.
Un nombre entier : 7
Son carré vaut 49
Pourquoi ça marche — Si int() échoue, le break n'est jamais atteint (l'exception saute directement à l'except) : on reboucle. S'il réussit, number existe et break sort. À la sortie de la boucle, number est GARANTI valide — c'est toute la valeur du motif. Note le test 7 : int() tolère les espaces autour, pas besoin de strip() ici.
Erreur classique sur cet exercice — Le break HORS du try (après l'except) : on sort même après une saisie invalide, et le print final plante en NameError (erreur fréquente n°4 de la leçon). Ou tester avec isdigit() au lieu d'attraper : "-4".isdigit() est faux alors que -4 est un entier légitime — l'exception couvre TOUS les cas, le test à la main non.
Variante plus difficile — Paramètre-le : écris ask_int(prompt, minimum, maximum) qui redemande aussi tant que le nombre est hors bornes. Deux validations, deux mécanismes : exception pour le format, if pour les bornes — pourquoi cette répartition ? (Réponse dans l'exercice difficile.)
Exercice facile b — prédire demo(...)
Raisonnement — On déroule la mécanique : int(value) réussit-il ? Si oui : else puis finally. Si non : except puis finally. Le troisième cas teste une connaissance de int(), pas de try.
Solution
demo("3") --- demo('3')
else: got 3
finally: always
demo("trois") --- demo('trois')
except: not a number
finally: always
demo(" 3 ") --- demo(' 3 ')
else: got 3 <- int() tolère les espaces autour !
finally: always
Pourquoi ça marche — else et except sont exclusifs (l'un OU l'autre), finally est inconditionnel. Et int(" 3 ") vaut bien 3 : int() retire lui-même les espaces et sauts de ligne autour du nombre — c'est d'ailleurs pour ça que int(f.read()) du compteur de 05-1 marchait même avec un \n... mais on garde le strip() explicite : compter sur un comportement implicite rend le code moins lisible.
Erreur classique sur cet exercice — Prédire except pour " 3 " — la majorité se trompe ici, c'était le but. Autre erreur : penser que finally ne s'exécute pas quand l'exception éclate — finally s'exécute TOUJOURS, c'est sa définition (même si l'exception n'était pas attrapée, il passerait avant le crash).
Variante plus difficile — Que ferait demo("3.0") ? Et si on remplaçait int(value) par float(value) ? Vérifie, puis explique pourquoi int("3.0") échoue alors que int(3.0) réussit (chaîne vs nombre : deux conversions différentes, ⏪ niveau 01-2).
Exercice facile c — le compteur qui survit au premier lancement
Raisonnement — Le cas « fichier absent » est PRÉVU et a une réponse propre (partir de 0) : c'est l'exemple 6 de la leçon appliqué au compteur de 05-1. Contrainte : le try n'entoure que la lecture — l'incrément et la réécriture ne peuvent pas lever FileNotFoundError (le mode "w" crée le fichier).
Solution
# ex-05-3-c.py (compteur.py, version robuste)
try:
with open("compteur.txt", encoding="utf-8") as f:
count = int(f.read().strip())
except FileNotFoundError:
count = 0 # first run: no file yet, start at zero
count += 1
with open("compteur.txt", "w", encoding="utf-8") as f:
f.write(str(count) + "\n")
print(f"Ce programme a été lancé {count} fois.")
Après del compteur.txt (ou suppression dans l'explorateur), le premier lancement affiche 1 — pas de traceback.
Pourquoi ça marche — L'except FileNotFoundError donne au compteur une valeur de départ ; le reste du programme ne voit aucune différence entre « premier lancement » et « lancement suivant ». Le except est précis : un compteur.txt contenant douze fait toujours un beau ValueError bruyant — c'est voulu, ce cas-là est un fichier corrompu, pas un cas normal.
Erreur classique sur cet exercice — Étendre le filet « tant qu'à faire » : except (FileNotFoundError, ValueError): count = 0 — séduisant, mais un fichier corrompu est alors silencieusement remis à zéro : tu perds de l'information sans le savoir. Si tu veux gérer la corruption, fais-le EXPLICITEMENT (message + décision), pas en la confondant avec l'absence.
Variante plus difficile — Justement : ajoute un second except ValueError qui affiche « fichier corrompu, contenu : ... », sauvegarde le fichier fautif sous compteur.txt.bak (⏪ 05-2 : Path.rename), et repart à zéro. C'est le comportement de l'extension 4 du gestionnaire de tâches.
Exercice moyen a — affiche_fichier.py
Raisonnement — Trois pannes distinctes → trois except, chacun avec un message qui aide VRAIMENT (dire quoi vérifier). L'affichage du contenu va dans le else : il ne doit tourner que si la lecture a réussi, et une erreur pendant l'affichage ne doit pas être maquillée en erreur de lecture.
Solution
# ex-05-3-d.py
filename = input("Fichier à afficher : ").strip()
try:
with open(filename, encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
print(f"'{filename}' n'existe pas — vérifie le nom et le dossier courant.")
except PermissionError:
print(f"'{filename}' n'est pas lisible — est-ce un dossier ?")
except UnicodeDecodeError:
print(f"'{filename}' n'est pas du texte utf-8 — autre encodage ou binaire.")
else:
print(content)
Les trois provocations (vérifiées) :
Fichier à afficher : nexiste-pas.txt
'nexiste-pas.txt' n'existe pas — vérifie le nom et le dossier courant.
Fichier à afficher : sorties <- un DOSSIER
'sorties' n'est pas lisible — est-ce un dossier ?
Fichier à afficher : vieux.txt <- écrit en cp1252
'vieux.txt' n'est pas du texte utf-8 — autre encodage ou binaire.
Pourquoi ça marche — Python teste les except dans l'ordre et n'exécute que le premier qui correspond au type levé. Ouvrir un dossier avec open() lève PermissionError sous Windows — surprenant mais réel, d'où l'indice dans le message. Le UnicodeDecodeError n'éclate qu'au read() (c'est le décodage qui échoue, pas l'ouverture) — il faut donc que le read() soit DANS le try, ce que notre structure garantit.
Erreur classique sur cet exercice — Un seul except (FileNotFoundError, PermissionError, UnicodeDecodeError): avec un message générique « erreur de lecture » : le programme ne plante plus, mais il n'AIDE plus — trois diagnostics différents méritent trois réponses. Et mettre print(content) dans le try : un bug d'affichage futur serait attrapé par accident.
Variante plus difficile — En cas de UnicodeDecodeError, retente automatiquement en cp1252 (un second try DANS l'except) et signale « affiché en cp1252 — pense à convertir ce fichier en utf-8 ». C'est une vraie stratégie de récupération, pas un maquillage : l'utilisateur est informé.
Exercice moyen b — parse_price(text)
Raisonnement — Une fonction de validation pure : elle normalise (strip, virgule → point), convertit (en LAISSANT float() lever pour les formats invalides — pourquoi réécrire sa police ?), et n'ajoute SON raise que pour la règle métier que float() ignore : pas de prix négatif. Les interdits (print, except dans la fonction) forcent la bonne répartition des rôles.
Solution
# ex-05-3-e.py
def parse_price(text):
"""Return the price as a float. Raise ValueError if invalid or negative."""
normalized = text.strip().replace(",", ".")
price = float(normalized) # ValueError for bad formats: let it rise
if price < 0:
raise ValueError(f"negative price: {text!r}")
return price
while True:
try:
price = parse_price(input("Prix : "))
break
except ValueError:
print("Prix invalide, réessaie (ex : 12,50).")
print(f"Prix enregistré : {price} €")
Vérifications : parse_price("12,50") → 12.5 ; " 8.99 " → 8.99 ; "-3" → ValueError: negative price: '-3' ; "douze" → ValueError: could not convert string to float: 'douze'.
Pourquoi ça marche — Les deux chemins d'échec (format et négatif) convergent vers le MÊME type, ValueError : l'appelant n'a qu'un filet à poser. Le !r dans le message affiche la saisie entre quotes ('-3') — précieux quand la saisie contient des espaces invisibles. Et la boucle appelante est le SEUL endroit qui parle à l'utilisateur : la fonction reste réutilisable partout (fichier, test, autre menu).
Erreur classique sur cet exercice — Attraper la ValueError de float() dans la fonction pour la relancer avec son propre message : du bruit pour rien — le message de float() est déjà bon, laisse-le monter. Inverse plus grave : return -1 ou return None pour signaler l'échec — on retombe dans l'erreur fréquente n°3 de la leçon, le mensonge qui se propage.
Variante plus difficile — Accepte aussi "12,50 €" et "€12.50" (retirer le symbole avant conversion). Puis durcis : refuse plus de deux décimales (ValueError dédiée) — indice : normalized.split(".") et regarde la longueur de la partie décimale.
Exercice moyen c — le bug enterré sous l'except: nu
Raisonnement — Démarche imposée par l'énoncé : observer (le code « marche » et renvoie 0 pour une liste vide… et aussi 20.0 pour la bonne liste — où est le problème ?), puis REMPLACER le filet aveugle par un filet précis pour faire parler le code.
Solution
Étape 1 — le diagnostic. En remplaçant except: par except ZeroDivisionError:, on voit que average([10, 20, 30]) marche… mais attends : recopie bien le code de l'énoncé. Avec except: nu, AUCUN traceback ne sortira jamais — y compris le jour où quelqu'un introduit une faute de frappe :
def average(values):
try:
totl = sum(values)
return totl / len(values) # si on tape "total" ici -> NameError AVALÉ
except:
return 0
Le vrai danger démontré : ce except: transforme n'importe quel bug futur (NameError, TypeError si values contient une chaîne…) en un silencieux return 0. Teste : average([10, "20", 30]) renvoie 0 au lieu de signaler le TypeError — résultat faux, zéro indice.
Étape 2 — la version propre :
def average(values):
"""Return the arithmetic mean. Raise ValueError on an empty list."""
if not values:
raise ValueError("average() of an empty list")
return sum(values) / len(values)
print(average([10, 20, 30])) # 20.0
# Pour une liste vide : lever, pas renvoyer 0. Une moyenne de 0 EXISTE
# (ex : [5, -5]) — renvoyer 0 pour "pas de données" rend les deux cas
# indistinguables. L'absence de données est une anomalie que l'appelant
# doit voir et décider de traiter.
Pourquoi ça marche — Plus de try du tout : la seule erreur PRÉVISIBLE (liste vide) est détectée par un simple if et signalée par un raise explicite avec message. Les erreurs IMprévisibles (types farfelus, fautes de frappe) planteront bruyamment — exactement ce qu'on veut pour des bugs. Toutes les vérifications ne méritent pas une exception… et toutes les exceptions ne méritent pas un try.
Erreur classique sur cet exercice — « Corriger » en except ZeroDivisionError: return 0 : mieux que le nu, mais le mensonge 0 demeure. Et répondre « renvoyer 0 » à la question incluse : relis le commentaire ci-dessus — un code d'erreur qui est aussi une valeur légitime est un piège que tu retrouveras dans de vieux code partout.
Variante plus difficile — sum(values) sur [10, "20"] lève TypeError. Faut-il l'attraper dans average ? Argumente les deux côtés, puis tranche. (Position du cours : non — des types invalides sont un bug de l'APPELANT, le traceback doit le pointer lui.)
Exercice difficile a — la liste de courses indestructible
Raisonnement — Assemblage de tous les motifs de la leçon sur le projet du niveau 03 : chargement avec filet FileNotFoundError (exemple 6), sauvegarde après chaque modification (aucun filet nécessaire : "w" crée le fichier), et validation de saisie à deux étages — exception pour « est-ce un entier ? », simple if pour « est-il dans les bornes ? ». Découpage en fonctions (⏪ niveau 04) pour que chaque filet vive au bon endroit.
Solution
# ex-05-3-f.py (courses_app.py)
SAVE_FILE = "courses.txt"
def load_items():
"""Return the saved list, or an empty list on first run."""
try:
with open(SAVE_FILE, encoding="utf-8") as f:
items = []
for line in f:
items.append(line.strip())
return items
except FileNotFoundError:
return []
def save_items(items):
with open(SAVE_FILE, "w", encoding="utf-8") as f:
for item in items:
f.write(item + "\n")
def show_items(items):
if not items:
print("Liste vide.")
for i, item in enumerate(items, start=1):
print(f"{i}. {item}")
def ask_index(items):
"""Ask for a 1-based item number until valid, return the 0-based index."""
while True:
try:
number = int(input("Numéro à retirer : "))
except ValueError:
print("Entier attendu.")
continue
if 1 <= number <= len(items):
return number - 1
print(f"Numéro entre 1 et {len(items)} attendu.")
def main():
items = load_items()
while True:
choice = input("(a)jouter, (r)etirer, (l)ister, (q)uitter : ").strip().lower()
if choice == "a":
item = input("Article : ").strip()
if item == "":
print("Article vide ignoré.")
continue
items.append(item)
save_items(items)
elif choice == "r":
if not items:
print("Liste vide, rien à retirer.")
continue
items.pop(ask_index(items))
save_items(items)
elif choice == "l":
show_items(items)
elif choice == "q":
break
else:
print("Choix inconnu.")
main()
Testé (vérifié) : premier lancement sans fichier → Liste vide. sans traceback ; zero puis 9 comme numéro → deux messages, redemande ; article vide, choix inconnu, ligne vide → messages, retour au menu ; relance → les articles sont là.
Pourquoi ça marche — UN seul try/except FileNotFoundError, dans load_items — le seul endroit où le fichier peut légitimement manquer. ask_index combine les deux étages de validation : le continue après l'except renvoie au début de la boucle (format invalide), et le if des bornes n'a pas besoin d'exception — la situation se teste trivialement. save_items n'attrape rien : si l'écriture échoue (disque plein…), c'est une VRAIE anomalie qui doit faire du bruit. Sauvegarder après chaque modification borne la perte maximale à zéro : un Ctrl+C entre deux commandes ne coûte rien.
Erreur classique sur cet exercice — Le grand try autour de tout le main() « pour être sûr » : le programme devient implantable, ET indéboguable — chaque bug de développement s'affiche comme « Numéro invalide ». Aussi : oublier save_items après le pop (la suppression ne survit pas au redémarrage — testé ?), et le - 1 du numéro affiché vs l'indice (le classique du gestionnaire de tâches : supprimer le MAUVAIS article, en silence).
Variante plus difficile — (1) Ajoute la commande « vider la liste », avec confirmation oui/non — quelle validation, exception ou if ? (2) Rends SAVE_FILE robuste au dossier de lancement avec Path(__file__).parent (⏪ 05-2). (3) Passe au gestionnaire de tâches v2 (mini-projet) : mêmes motifs, données plus riches.
Mini-projet — « gestionnaire de tâches, v2 persistante »
Raisonnement — La fiche 06-gestionnaire-de-taches.md guide les étapes 6–8 ; ce qui relève de CETTE leçon tient en trois décisions : où va le filet (load_tasks uniquement), QUAND on sauvegarde (après chaque modification, jamais « à la fin »), et ce qu'on ne doit PAS attraper (les bugs). Le squelette de la partie persistance, en version fichier texte (une tâche par ligne, x|titre ou |titre) si tu fais l'étape avant d'avoir vu les imports :
Solution (partie persistance, format texte)
SAVE_FILE = "tasks.txt"
def load_tasks():
"""Return the task list, or an empty list on first run."""
try:
with open(SAVE_FILE, encoding="utf-8") as f:
tasks = []
for line in f:
done_mark, title = line.rstrip("\n").split("|", 1)
tasks.append({"title": title, "done": done_mark == "x"})
return tasks
except FileNotFoundError:
return []
def save_tasks(tasks):
with open(SAVE_FILE, "w", encoding="utf-8") as f:
for task in tasks:
done_mark = "x" if task["done"] else " "
f.write(f"{done_mark}|{task['title']}\n")
Pourquoi ça marche — Une tâche = un dict (le cahier des charges de la fiche), sérialisé en une ligne x|titre. split("|", 1) coupe au PREMIER | seulement : un titre contenant | survit. Le filet FileNotFoundError rend le premier lancement propre ; tout le reste du programme ignore l'existence du fichier. (La fiche fait la même chose en JSON — json.load/json.dump remplacent notre sérialisation maison, et c'est leur métier ; après 05-4 tu sauras écrire l'import json.)
Erreur classique sur cet exercice — Sauvegarder uniquement sur q : la fiche l'interdit explicitement — un crash perd la session. Et le format maison fragile : couper avec split("|") SANS le 1 casse dès qu'un titre contient un | — teste-le ; c'est précisément le genre de coin de rue où JSON t'attend.
Variante plus difficile — L'extension 4 de la fiche : au chargement, si une ligne est malformée (ValueError au split… vérifie : est-ce bien ValueError ?), ne perds RIEN — renomme le fichier en .bak, signale, et repars à vide. Écris d'abord le test : un tasks.txt corrompu à la main.