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 marcheelse 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 difficilesum(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.