From 55d7abea8683b3f4279eab94cf47798a52526ef2 Mon Sep 17 00:00:00 2001 From: Alpinux Date: Thu, 15 Oct 2026 00:28:56 +0200 Subject: [PATCH] vault backup: 2026-10-15 00:28:56 --- .../ID01 - Lire, remplir et signer ses PDF.md | 110 + ...2 - Ranger et sauvegarder ses documents.md | 16 + ... Les outils de sauvegarde de Linux Mint.md | 16 + .../ID04 - Nextcloud au quotidien.md | 18 + .../Pix orientée Libre/0 - Compétences PIX.md | 44 + .../Adapter les documents à leur finalité.md | 74 + .../Pix orientée Libre/Collaborer.md | 65 + .../Construire un environnement numérique.md | 79 + .../Développer des documents multimédia.md | 81 + .../Développer des documents textuels.md | 73 + .../Pix orientée Libre/Gérer des données.md | 75 + .../Pix orientée Libre/Interagir.md | 55 + ...ne recherche et une veille d'information.md | 66 + .../Pix orientée Libre/Partager et publier.md | 63 + .../Pix orientée Libre/Programmer.md | 74 + ...la santé, le bien-être et l'environnement.md | 57 + ...les données personnelles et la vie privée.md | 75 + .../Résoudre des problèmes techniques.md | 91 + .../S'insérer dans le monde numérique.md | 63 + .../Sécuriser l'environnement numérique.md | 73 + .../Pix orientée Libre/Traiter des données.md | 66 + .../Python/00-installation-environnement.md | 932 ++++++ .../Python/01-variables-types-operateurs.md | 1616 ++++++++++ docs/presentations/Python/02-conditions.md | 1680 +++++++++++ docs/presentations/Python/03-boucles.md | 1688 +++++++++++ .../Python/04-structures-de-donnees.md | 1717 +++++++++++ docs/presentations/Python/05-fonctions.md | 1630 ++++++++++ .../Python/06-chaines-et-comprehensions.md | 1598 ++++++++++ .../Python/07-fichiers-csv-json.md | 2010 +++++++++++++ .../Python/08-erreurs-et-debogage.md | 1694 +++++++++++ .../09-modules-bibliotheque-standard.md | 1777 +++++++++++ .../Python/10-poo-classes-objets.md | 1807 ++++++++++++ .../Python/11-poo-avancee-heritage-typage.md | 2490 ++++++++++++++++ .../Python/12-outils-venv-pip-git.md | 1524 ++++++++++ .../13-tests-pytest-bonnes-pratiques.md | 1956 ++++++++++++ .../Python/14-specialisations.md | 2274 ++++++++++++++ docs/presentations/Python/15-projet-final.md | 2620 +++++++++++++++++ docs/presentations/Python/README.md | 130 + docs/presentations/Python/aide-memoire.md | 526 ++++ docs/presentations/Python/ressources.md | 104 + 40 files changed, 31107 insertions(+) create mode 100644 docs/presentations/ID01 - Lire, remplir et signer ses PDF.md create mode 100644 docs/presentations/ID02 - Ranger et sauvegarder ses documents.md create mode 100644 docs/presentations/ID03 - Les outils de sauvegarde de Linux Mint.md create mode 100644 docs/presentations/ID04 - Nextcloud au quotidien.md create mode 100644 docs/presentations/Pix orientée Libre/0 - Compétences PIX.md create mode 100644 docs/presentations/Pix orientée Libre/Adapter les documents à leur finalité.md create mode 100644 docs/presentations/Pix orientée Libre/Collaborer.md create mode 100644 docs/presentations/Pix orientée Libre/Construire un environnement numérique.md create mode 100644 docs/presentations/Pix orientée Libre/Développer des documents multimédia.md create mode 100644 docs/presentations/Pix orientée Libre/Développer des documents textuels.md create mode 100644 docs/presentations/Pix orientée Libre/Gérer des données.md create mode 100644 docs/presentations/Pix orientée Libre/Interagir.md create mode 100644 docs/presentations/Pix orientée Libre/Mener une recherche et une veille d'information.md create mode 100644 docs/presentations/Pix orientée Libre/Partager et publier.md create mode 100644 docs/presentations/Pix orientée Libre/Programmer.md create mode 100644 docs/presentations/Pix orientée Libre/Protéger la santé, le bien-être et l'environnement.md create mode 100644 docs/presentations/Pix orientée Libre/Protéger les données personnelles et la vie privée.md create mode 100644 docs/presentations/Pix orientée Libre/Résoudre des problèmes techniques.md create mode 100644 docs/presentations/Pix orientée Libre/S'insérer dans le monde numérique.md create mode 100644 docs/presentations/Pix orientée Libre/Sécuriser l'environnement numérique.md create mode 100644 docs/presentations/Pix orientée Libre/Traiter des données.md create mode 100644 docs/presentations/Python/00-installation-environnement.md create mode 100644 docs/presentations/Python/01-variables-types-operateurs.md create mode 100644 docs/presentations/Python/02-conditions.md create mode 100644 docs/presentations/Python/03-boucles.md create mode 100644 docs/presentations/Python/04-structures-de-donnees.md create mode 100644 docs/presentations/Python/05-fonctions.md create mode 100644 docs/presentations/Python/06-chaines-et-comprehensions.md create mode 100644 docs/presentations/Python/07-fichiers-csv-json.md create mode 100644 docs/presentations/Python/08-erreurs-et-debogage.md create mode 100644 docs/presentations/Python/09-modules-bibliotheque-standard.md create mode 100644 docs/presentations/Python/10-poo-classes-objets.md create mode 100644 docs/presentations/Python/11-poo-avancee-heritage-typage.md create mode 100644 docs/presentations/Python/12-outils-venv-pip-git.md create mode 100644 docs/presentations/Python/13-tests-pytest-bonnes-pratiques.md create mode 100644 docs/presentations/Python/14-specialisations.md create mode 100644 docs/presentations/Python/15-projet-final.md create mode 100644 docs/presentations/Python/README.md create mode 100644 docs/presentations/Python/aide-memoire.md create mode 100644 docs/presentations/Python/ressources.md diff --git a/docs/presentations/ID01 - Lire, remplir et signer ses PDF.md b/docs/presentations/ID01 - Lire, remplir et signer ses PDF.md new file mode 100644 index 0000000..ea4f9d3 --- /dev/null +++ b/docs/presentations/ID01 - Lire, remplir et signer ses PDF.md @@ -0,0 +1,110 @@ +**Format** : Court (20 min) + +**Objectif :** ne plus imprimer un formulaire pour le signer puis le scanner. + +**Déroulé** + +1. Le lecteur de Linux Mint (Xreader) : lire, chercher, surligner. +2. Okular : annoter et remplir un formulaire. +3. Xournal++ : apposer une signature manuscrite. +4. PDF Arranger : fusionner, découper, réordonner des pages. +5. Exporter en PDF depuis LibreOffice, réduire le poids pour un envoi par mail. + +**Démo :** remplir et signer un vrai formulaire, le fusionner avec une pièce justificative. + +-- + +Voilà ce que nous voulons faire : Un dossier d’inscription à envoyer par mail. C'est un formulaire à remplir et signer, une pièce justificative à joindre, le tout en un seul PDF léger. Chaque outil résout une étape. + +### Avant la séance + +**Fichiers à préparer** + +- Un formulaire PDF avec champs modifiables (un CERFA convient). +- Un formulaire sans champs, par exemple un document scanné. +- Un justificatif en image (photo d’une facture fictive). +- Une signature sur papier blanc, photographiée et enregistrée en PNG. + +**Installation** (Xreader et LibreOffice sont déjà dans Linux Mint) : + +``` +sudo apt install okular xournalpp pdfarranger +``` + +Les trois sont aussi dans la Logithèque, à utiliser si vous ne vous sentez pas à l'aise avec le terminal. + +### 1. Xreader, le lecteur déjà installé + +- Ouvrir le formulaire par double-clic. +- Le panneau latéral : vignettes des pages et sommaire. +- Chercher un mot avec Ctrl+F. +- Surligner un passage et ajouter une note depuis le panneau des annotations. +- Remplir directement un champ du formulaire. + +**À noter :** pour lire et pour un formulaire simple, il n’y a rien à installer. + +**Piège :** utiliser « Enregistrer une copie », sinon les annotations et la saisie sont perdues à la fermeture. + +### 2. Okular, pour annoter et remplir + +- La barre d’annotations (F6) : surligneur, note, trait à main levée. +- Le bouton « Afficher les formulaires », puis la saisie des champs et des cases à cocher. +- L’outil « machine à écrire » : il permet d’écrire sur un PDF qui n’a pas de champs, cas très fréquent avec les documents scannés. +- Enregistrer avec Ctrl+S, puis rouvrir le fichier dans Xreader pour prouver que le texte est bien dedans. + +**À retenir :** Okular vient de l’environnement KDE mais fonctionne très bien sous Mint. C’est l’outil à retenir si l’on ne devait en installer qu’un. + +**Piège :** un PDF protégé peut refuser la saisie. Dans ce cas, passer à Xournal++. + +### 3. Xournal++, pour signer + +- Fichier > Annoter un PDF, puis ouvrir le formulaire rempli. +- Méthode 1 : signer au stylet ou à la souris avec l’outil stylo (résultat médiocre à la souris, le montrer avec humour). +- Méthode 2, la bonne : insérer l’image de sa signature, la redimensionner et la placer. +- Ajouter la date avec l’outil texte. +- Fichier > Exporter en PDF. + +**À noter :** une signature collée en image est acceptée pour beaucoup de démarches courantes, mais ce n’est pas une signature électronique qualifiée. Pour un acte important, l’organisme impose généralement son propre service. + +**Piège :** « Enregistrer » produit un fichier .xopp, illisible pour le destinataire. Il faut exporter en PDF. C’est l’erreur numéro un. + +**Conseil de sécurité :** ranger le PNG de sa signature dans un dossier discret, et ne pas le laisser traîner sur une clé USB. + +### 4. PDF Arranger, pour assembler + +- Glisser le formulaire signé et le justificatif dans la fenêtre. +- Réordonner les pages à la souris. +- Faire pivoter une page scannée de travers. +- Supprimer une page blanche. +- Extraire une seule page d’un long document : sélectionner, puis exporter la sélection. +- Enregistrer sous un nouveau nom. + +**À noter :** le logiciel accepte aussi les images, donc une photo de justificatif devient une page PDF sans étape intermédiaire. + +**Piège :** enregistrer sous un nouveau nom pour ne pas écraser l’original. + +### 5. Exporter et alléger + +- Dans LibreOffice Writer : Fichier > Exporter vers PDF. +- Dans les options : compression JPEG et réduction de la résolution des images à 150 DPI. Comparer le poids avant et après dans le gestionnaire de fichiers. +- Mentionner l’option PDF hybride, qui embarque le fichier d’origine et permet de le remodifier plus tard. + +**Pour un PDF déjà existant et trop lourd**, typiquement un scan, une commande suffit : + +``` +gs -sDEVICE=pdfwrite -dPDFSETTINGS=/ebook -dNOPAUSE -dBATCH -sOutputFile=leger.pdf lourd.pdf +``` + +**À noter :** les sites de compression en ligne fonctionnent, mais on y envoie sa pièce d’identité ou son avis d’imposition à un inconnu. Tout ce qui a été montré ce soir reste sur l’ordinateur. + +### Conclusion + +Rappeler le parcours : remplir (Okular), signer (Xournal++), assembler (PDF Arranger), alléger, envoyer. Pas d’imprimante, pas de scanner, pas de service en ligne. + +|Je veux|J’utilise| +|---|---| +|Lire, chercher, surligner|Xreader| +|Remplir un formulaire, écrire sur un PDF|Okular| +|Signer|Xournal++| +|Fusionner, découper, pivoter|PDF Arranger| +|Créer un PDF léger|LibreOffice| \ No newline at end of file diff --git a/docs/presentations/ID02 - Ranger et sauvegarder ses documents.md b/docs/presentations/ID02 - Ranger et sauvegarder ses documents.md new file mode 100644 index 0000000..cce803f --- /dev/null +++ b/docs/presentations/ID02 - Ranger et sauvegarder ses documents.md @@ -0,0 +1,16 @@ +**Format** : Court (20 min) + +**Objectif :** savoir quoi sauvegarder et où, avant de parler d’outil. + +**Déroulé** + +1. Où sont mes fichiers : le dossier personnel, Documents, Images (rappel de la présentation existante). +2. Ce qui est irremplaçable (photos, papiers) et ce qui ne l’est pas (logiciels, système). +3. La règle 3-2-1 expliquée simplement : trois copies, deux supports, une ailleurs. +4. Choisir son support : disque externe, clé USB, [[NAS]], [[Nextcloud]]. +5. Synchronisation et sauvegarde ne sont pas la même chose : une suppression par erreur se propage. + +**Démo :** copier son dossier Documents sur un disque externe, puis vérifier que la copie s’ouvre. + +**Remarque :** « Les sauvegardes » figure déjà dans les présentations passées et un guide existe sur le wiki. Cette séance peut en être la version actualisée, centrée sur les documents personnels. +[sauvegardes](../guides/sauvegardes.md) \ No newline at end of file diff --git a/docs/presentations/ID03 - Les outils de sauvegarde de Linux Mint.md b/docs/presentations/ID03 - Les outils de sauvegarde de Linux Mint.md new file mode 100644 index 0000000..a2a04ae --- /dev/null +++ b/docs/presentations/ID03 - Les outils de sauvegarde de Linux Mint.md @@ -0,0 +1,16 @@ +**Format** : Long (45 min) + +**Objectif :** que chacun reparte avec une sauvegarde configurée sur sa machine. + +**Déroulé** + +1. Deux outils, deux rôles : Timeshift pour le système, l’Outil de sauvegarde pour les fichiers personnels. +2. Timeshift : choisir le disque, la fréquence, le nombre d’instantanés conservés, puis restaurer après une mise à jour ratée. +3. Outil de sauvegarde : sauvegarder son dossier personnel, exclure des dossiers, restaurer. +4. Sauvegarder la liste de ses logiciels, pratique pour une réinstallation. +5. Pour aller plus loin : Déjà Dup ou Pika Backup, avec planification et chiffrement. +6. Tester une restauration, l’étape que tout le monde oublie. + +**Démo :** supprimer volontairement un fichier, puis le restaurer. + +**À prévoir :** demander aux participants d’apporter leur portable et un disque externe. \ No newline at end of file diff --git a/docs/presentations/ID04 - Nextcloud au quotidien.md b/docs/presentations/ID04 - Nextcloud au quotidien.md new file mode 100644 index 0000000..f33614b --- /dev/null +++ b/docs/presentations/ID04 - Nextcloud au quotidien.md @@ -0,0 +1,18 @@ + +**Format :** long, 45 min + +**Objectif :** utiliser [[Nextcloud]] côté utilisateur, sans parler d’installation de serveur. + +**Déroulé** + +1. À quoi ça sert : ses fichiers accessibles partout, sans Google Drive ni OneDrive. +2. L’interface web : déposer, ranger, retrouver une version précédente, corbeille. +3. Le client de synchronisation sous Linux Mint : choisir les dossiers. +4. L’application mobile : envoi automatique des photos. +5. Partager : lien public, mot de passe, date d’expiration. +6. Agenda et contacts, reliés à Thunderbird. +7. Les limites : quota, et toujours pas une sauvegarde à lui seul. + +**Démo :** partager un dossier avec la salle par un code QR, chacun y dépose une photo. + +**Atout :** le portail membres d’Alpinux donne déjà accès à un Nextcloud, donc les adhérents peuvent pratiquer tout de suite, et c’est un argument d’adhésion. [alpinux](https://alpinux.org/) \ No newline at end of file diff --git a/docs/presentations/Pix orientée Libre/0 - Compétences PIX.md b/docs/presentations/Pix orientée Libre/0 - Compétences PIX.md new file mode 100644 index 0000000..f6bf9cd --- /dev/null +++ b/docs/presentations/Pix orientée Libre/0 - Compétences PIX.md @@ -0,0 +1,44 @@ +Le référentiel Pix (aligné sur le CRCN, le Cadre de référence des compétences numériques) compte 16 compétences rassemblées en 5 grands domaines, décliné du cadre commun de référence européen DIGCOMP 2.1. Les voici : + +**1. Information et données** + +- 1.1 [[Mener une recherche et une veille d'information]] +- 1.2 [[Gérer des données]] +- 1.3 [[Traiter des données]] + +**2. Communication et collaboration** + +- 2.1 [[Interagir]] +- 2.2 [[Partager et publier]] +- 2.3 [[Collaborer]] +- 2.4 [[S'insérer dans le monde numérique]] + +**3. Création de contenu** + +- 3.1 [[Développer des documents textuels]] +- 3.2 [[Développer des documents multimédia]] +- 3.3 [[Adapter les documents à leur finalité]] +- 3.4 [[Programmer]] + +**4. Protection et sécurité** + +- 4.1 [[Sécuriser l'environnement numérique]] +- 4.2 [[Protéger les données personnelles et la vie privée]] +- 4.3 [[Protéger la santé, le bien-être et l'environnement]] + +**5. Environnement numérique** + +- 5.1 [[Résoudre des problèmes techniques]] +- 5.2 [[Construire un environnement numérique]] + +Quelques repères utiles : le cadre prévoit 8 niveaux de maîtrise progressive, les niveaux 1 à 5 concernant les élèves de collège et de lycée. Chaque compétence est décomposée en sous-compétences, elles-mêmes déclinées en acquis par niveau, et côté score, une compétence validée au niveau le plus élevé « rapporte » 64 Pix (8 Pix par niveau). Enfin, la certification doit être renouvelée tous les 3 ans. + +Le détail de chaque compétence est consultable sur pix.fr/competences. + +Sources : + +- [France Compétences – Certificat Pix](https://www.francecompetences.fr/recherche/rs/3807) +- [France Compétences – fiche Pix](https://certifpro.francecompetences.fr/api/fiches/downloadAncFormat/18995) +- [Collège Alfred Kastler – Parcours Pix 3e](https://alfred-kastler.college.ac-normandie.fr/IMG/pdf/parcours_de_formation_pix_3e.pdf) +- [Collège Anna Marly – Pix et continuité pédagogique](https://www.college-anna-marly-brest.ac-rennes.fr/sites/college-anna-marly-brest.ac-rennes.fr/IMG/pdf/pix_et_continuite_pedagogique.pdf) + diff --git a/docs/presentations/Pix orientée Libre/Adapter les documents à leur finalité.md b/docs/presentations/Pix orientée Libre/Adapter les documents à leur finalité.md new file mode 100644 index 0000000..81310c9 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Adapter les documents à leur finalité.md @@ -0,0 +1,74 @@ +## 3.3 Adapter les documents à leur finalité + +**Ce que recouvre la compétence** : transformer un document selon son usage (impression, mise en ligne, projection, mises à jour fréquentes, diffusion sur plusieurs supports) et maîtriser les licences qui encadrent sa réutilisation. Thématiques Pix : choix et conversion de formats, poids et qualité des fichiers, interopérabilité, accessibilité, licences. + +### Progression par niveaux (résumé) + +- **N1-N2** : enregistrer un document dans un autre format, exporter en PDF. +- **N3** : choisir le format selon l'usage (modifiable ou figé), réduire le poids d'une image avant envoi. +- **N4-N5** : préparer un document pour l'impression ou la projection, appliquer une licence à sa production, respecter celles des ressources réutilisées. +- **N6+** : organiser une production multisupport à partir d'une source unique, automatiser les conversions, gérer la compatibilité des licences. + +### Version Linux et libre + +**Choisir le bon format selon l'usage** + +- À modifier : ODT, ODS, ODP, Markdown +- À diffuser figé ou imprimer : PDF ; à archiver : PDF/A +- Pour le web : HTML ; pour une liseuse : EPUB +- Images : PNG (schémas), JPEG ou WebP (photos), SVG (vectoriel) +- Audio et vidéo : Opus, FLAC, WebM (formats ouverts) ; MP3 et MP4 pour la compatibilité + +**Convertir** + +- LibreOffice en ligne de commande : `soffice --headless --convert-to pdf *.odt` pour tout un dossier +- **Pandoc** : une source Markdown, plusieurs sorties (principe de la diffusion multicanale) +- Images : **ImageMagick**, Converseen (par lots), Switcheroo (GNOME) +- Audio et vidéo : **FFmpeg**, HandBrake + +**Alléger et optimiser** + +- Compression avec ou sans perte : comprendre le compromis +- Images : Curtail, ou `magick photo.jpg -resize 1200x photo-web.jpg` +- PDF trop lourd : Ghostscript, réglages d'export de LibreOffice +- Assembler, découper, réordonner des PDF : PDF Arranger, `qpdf` + +**Préparer pour l'impression** + +- Résolution (300 ppp), marges, fonds perdus +- Couleurs CMJN avec Scribus +- Impression en livret depuis LibreOffice, gestion des imprimantes avec CUPS + +**Préparer pour la projection** + +- Format 16:9, gros caractères, contrastes forts +- PDF avec polices incorporées pour éviter les surprises sur un autre poste + +**Documents mis à jour souvent** + +- Garder la source modifiable, versionner (lien 2.3) +- Champs automatiques (dates, numéros), modèles, publipostage + +**Accessibilité** + +- PDF balisé, texte alternatif +- **OCRmyPDF** : rendre un PDF scanné lisible et cherchable + +**Avant diffusion** + +- Retirer métadonnées et traces de révisions : option de LibreOffice pour supprimer les informations personnelles à l'enregistrement, **mat2** + +**Licences** + +- Creative Commons : choisir, afficher la mention dans le document et ses propriétés +- Compatibilité : on ne peut pas mélanger librement BY-SA et BY-NC dans une même œuvre +- **CC0** pour le domaine public, **Licence Ouverte (Etalab)** pour les données publiques, **GPL** pour le code +- Vérifier la licence de chaque ressource réutilisée et la créditer + +### Idées d'activités + +- Partir d'un même contenu et produire trois versions : une affiche imprimable, une page web légère, un diaporama à projeter +- Convertir tout un dossier de documents en PDF en une seule commande +- Défi poids : envoyer un document illustré de 25 Mo en moins de 2 Mo sans perte visible de qualité +- Rendre un PDF scanné accessible avec OCRmyPDF, puis tester avec un lecteur d'écran (Orca) +- Construire un document à partir de ressources sous différentes licences et vérifier si la combinaison est autorisée diff --git a/docs/presentations/Pix orientée Libre/Collaborer.md b/docs/presentations/Pix orientée Libre/Collaborer.md new file mode 100644 index 0000000..5218679 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Collaborer.md @@ -0,0 +1,65 @@ +## 2.3 Collaborer + +**Ce que recouvre la compétence** : travailler à plusieurs pour réaliser un projet, coproduire des documents, des données ou des connaissances, et apprendre ensemble. Thématiques Pix : outils d'édition partagée, suivi des modifications et gestion de versions, droits d'accès, organisation du travail en groupe, production de communs. + +### Progression par niveaux (résumé) + +- **N1-N2** : modifier un document partagé, laisser un commentaire. +- **N3** : utiliser le suivi des modifications et l'historique des versions, respecter le travail des autres. +- **N4-N5** : organiser un projet de groupe (répartition des tâches, échéances), gérer les droits d'accès d'un espace partagé. +- **N6+** : choisir et mettre en place un environnement collaboratif adapté, gérer les versions et les conflits, coordonner une production à grande échelle. + +### Version Linux et libre + +**Édition simultanée** + +- **CryptPad** : texte, tableur, présentation, kanban, formulaires, tableau blanc, le tout chiffré de bout en bout +- **Etherpad** : texte en temps réel avec une couleur par auteur, très lisible pour voir qui a écrit quoi +- **HedgeDoc** : Markdown collaboratif +- Nextcloud avec **Collabora Online** ou **OnlyOffice** : bureautique complète dans le navigateur + +**Travailler hors ligne, à plusieurs** + +- LibreOffice : suivi des modifications (accepter/refuser), commentaires, comparaison de deux versions d'un document + +**Gestion de versions** + +- Historique des versions dans Nextcloud et CryptPad : revenir en arrière +- **Git** : `clone`, `commit`, `pull`, `push`, branches, résolution de conflits +- Forges libres : **Codeberg**, GitLab, Forgejo (auto-hébergeable) +- Tickets (issues) et demandes de fusion : le modèle de collaboration du logiciel libre + +**Organisation et suivi de projet** + +- Kanban : Nextcloud Deck, Kanboard, Wekan, Vikunja +- Gestion de projet plus poussée : OpenProject, Taiga +- Tableaux blancs : Excalidraw, draw.io, tableau blanc de BigBlueButton + +**Bases de connaissances** + +- Wikis d'équipe : BookStack, Wiki.js, DokuWiki + +**Espaces de travail complets** + +- **Nextcloud** : fichiers, agenda, discussions, tâches au même endroit +- Framaspace (Nextcloud pour associations et collectifs) +- apps.education.fr pour les personnels de l'Éducation nationale +- Discussions d'équipe : Matrix/Element, Mattermost + +**Droits d'accès** + +- Lecture, commentaire, édition, administration : qui peut faire quoi +- Liens publics vs accès nominatif + +**Méthodes de travail** + +- Définir des rôles, des conventions de nommage, un calendrier +- Décider ensemble des règles (gouvernance), comme dans les projets libres + +### Idées d'activités + +- Rédaction à plusieurs sur Etherpad, puis analyse des couleurs : la contribution est-elle équilibrée ? +- Relecture croisée d'un texte dans LibreOffice avec suivi des modifications +- Gérer un projet de groupe de bout en bout avec un tableau Deck ou Kanboard +- Premier pas avec Git sur Codeberg : chacun modifie un fichier, on provoque puis on résout un conflit +- Observer un vrai projet libre (tickets, discussions, contributions) pour comprendre comment des milliers de personnes collaborent sans se connaître diff --git a/docs/presentations/Pix orientée Libre/Construire un environnement numérique.md b/docs/presentations/Pix orientée Libre/Construire un environnement numérique.md new file mode 100644 index 0000000..a6d90b6 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Construire un environnement numérique.md @@ -0,0 +1,79 @@ +## 5.2 Construire un environnement numérique + +**Ce que recouvre la compétence** : installer, configurer et enrichir un environnement (matériel, système, logiciels, services) adapté à ses activités, à son contexte et à ses valeurs. C'est la compétence où le choix de Linux et du libre devient lui-même l'objet d'apprentissage. Thématiques Pix : composants matériels, systèmes d'exploitation, installation de logiciels, configuration et personnalisation, périphériques et réseau, choix de services. + +### Progression par niveaux (résumé) + +- **N1-N2** : allumer et prendre en main un appareil, connecter un périphérique, personnaliser le fond d'écran ou la langue. +- **N3** : installer et désinstaller une application, connecter un appareil au réseau, choisir ses applications par défaut. +- **N4-N5** : configurer un système (comptes, partage, périphériques), choisir des logiciels et services selon des critères explicites. +- **N6+** : installer un système d'exploitation, concevoir un environnement complet pour un usage ou un groupe, auto-héberger des services. + +### Version Linux et libre + +**Comprendre les couches** + +- Matériel (processeur, mémoire, stockage, carte graphique), micrologiciel UEFI +- Noyau Linux, pilotes, système GNU, environnement de bureau, applications +- Une même base, de nombreuses combinaisons : c'est ce qui rend les choix visibles et compréhensibles + +**Choisir sa distribution et son bureau** + +- Pour débuter : **Linux Mint**, Ubuntu, Zorin OS +- Stables et communautaires : **Debian**, Fedora, openSUSE +- Pour apprendre en profondeur : Arch Linux +- Contextes particuliers : **PrimTux** (écoles primaires, projet français), Emmabuntüs (réemploi) +- Environnements de bureau : GNOME, KDE Plasma, Xfce, Cinnamon, MATE, LXQt (du plus riche au plus léger) + +**Installer le système** + +- Tester sans rien modifier grâce au mode live +- Créer une clé d'installation : Impression d'image disque (GNOME), balenaEtcher, Ventoy +- Choix d'installation : remplacer l'ancien système ou cohabiter (double démarrage), partitionnement, chiffrement LUKS (lien 4.1), Secure Boot +- S'entraîner d'abord en machine virtuelle avec GNOME Boxes + +**Installer des logiciels** + +- Gestionnaire de paquets : `apt`, `dnf`, `pacman` +- Logithèques graphiques : Logiciels (GNOME), Discover (KDE) +- **Flatpak et Flathub**, Snap, AppImage : comprendre leurs différences, avantages et limites +- Règle d'or : privilégier les sources officielles plutôt que des fichiers téléchargés au hasard + +**Configurer et personnaliser** + +- Comptes utilisateurs et groupes, droits d'administration +- Langue, disposition du clavier, raccourcis, applications par défaut, lancement au démarrage +- Extensions GNOME, widgets KDE, thèmes +- Les réglages sont stockés dans `~/.config` : sauvegarder et transporter son environnement (_dotfiles_) + +**Périphériques et réseau** + +- Imprimantes et scanners (lien 5.1) +- Partage de dossiers en réseau local avec Samba +- **KDE Connect** ou GSConnect (GNOME) : relier son téléphone à son ordinateur (fichiers, notifications, presse-papiers) + +**Choisir ses services selon ses valeurs** + +- Hébergeurs du collectif CHATONS, Framaspace, Nextcloud (lien 1.2 et 2.3) +- Téléphone : F-Droid, /e/OS (lien 4.2) +- Critères à expliciter : liberté du logiciel, formats ouverts, vie privée, localisation des données, sobriété, coût + +**Auto-héberger** + +- **YunoHost** (projet français) : installer Nextcloud, FreshRSS, Wallabag et d'autres en quelques clics sur un serveur ou un Raspberry Pi +- Pour aller plus loin : conteneurs avec Podman ou Docker + +**Penser l'environnement comme un tout** + +- Mises à jour, sauvegardes, sécurité : un plan de maintenance (liens 1.2, 4.1, 5.1) +- Interopérabilité avec les utilisateurs d'autres systèmes : formats ouverts, services web +- Exemple de déploiement à grande échelle : la Gendarmerie nationale et ses dizaines de milliers de postes sous GendBuntu + +### Idées d'activités + +- Tester trois distributions en machine virtuelle et justifier son choix selon un profil d'utilisateur donné +- Installer Linux sur un vrai ordinateur, de la création de la clé à la configuration finale +- Concevoir un environnement « clé en main » pour un public précis (élève, association, personne âgée) et le documenter +- Monter un petit serveur YunoHost sur un Raspberry Pi avec deux ou trois services utiles au groupe +- Relier son téléphone à son PC avec KDE Connect +- Rédiger une charte de choix numériques pour une classe ou une association, argumentée par des valeurs diff --git a/docs/presentations/Pix orientée Libre/Développer des documents multimédia.md b/docs/presentations/Pix orientée Libre/Développer des documents multimédia.md new file mode 100644 index 0000000..5638ee9 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Développer des documents multimédia.md @@ -0,0 +1,81 @@ +## 3.2 Développer des documents multimédia + +**Ce que recouvre la compétence** : créer ses propres productions mêlant image, son, vidéo ou animation, enrichir un document textuel, ou réaliser une œuvre transformative (remix, détournement). Thématiques Pix : capture et édition d'images, de sons et de vidéos, image matricielle et vectorielle, formats et codecs, montage, droit d'auteur et droit à l'image. + +### Progression par niveaux (résumé) + +- **N1-N2** : faire une capture d'écran, recadrer une photo, enregistrer un son. +- **N3** : retoucher une image, couper et assembler des séquences audio ou vidéo, distinguer image matricielle et vectorielle. +- **N4-N5** : réaliser un montage complet (titres, transitions, musique, sous-titres), travailler avec des calques, choisir formats et réglages d'export. +- **N6+** : mener une production multimédia de bout en bout, créer des animations, réaliser des remix en respectant les licences. + +### Version Linux et libre + +**Notions de base à faire comprendre** + +- Pixels (matriciel) vs formes mathématiques (vectoriel) : effet d'un agrandissement +- Calques, résolution, profondeur de couleur +- Conteneur (MKV, WebM, MP4) vs codec (AV1, VP9, H.264 pour la vidéo ; Opus, AAC pour le son) +- Débit, images par seconde, fréquence d'échantillonnage + +**Captures** + +- Captures d'écran : outil intégré de GNOME (touche Impr. écran), Spectacle (KDE), **Flameshot** pour annoter +- Enregistrement d'écran : outil intégré de GNOME, Kooha, **OBS Studio** pour des tutoriels plus élaborés + +**Image matricielle** + +- **GIMP** : retouche, calques, masques +- **Krita** : dessin et peinture numérique +- Pinta : simple, pour débuter +- darktable ou RawTherapee : développement de photos RAW + +**Image vectorielle et schémas** + +- **Inkscape** : logos, affiches, illustrations, format SVG +- draw.io, Dia : schémas et organigrammes + +**Son** + +- **Audacity** : enregistrement, découpe, réduction de bruit, effets (idéal pour un podcast) +- Ardour : enregistrement multipiste +- LMMS : composition, MuseScore : partitions + +**Vidéo** + +- **Kdenlive** : montage complet, le plus abouti +- Shotcut, OpenShot : plus simples pour démarrer +- Sous-titres : Aegisub, Gaupol ; transcription automatique hors ligne avec des outils basés sur Whisper (par exemple Speech Note) + +**Animation et 3D** + +- Pencil2D : animation 2D image par image, accessible aux débutants +- Synfig Studio : animation 2D vectorielle +- **Blender** : 3D, animation, et même montage vidéo +- Sweet Home 3D : aménagement intérieur + +**Installation sous Linux** + +- **Flathub** pour obtenir les versions récentes de ces logiciels +- Codecs : certaines distributions demandent d'installer à part les codecs non libres (paquet « restricted extras » sur Ubuntu, par exemple) + +**Ressources libres pour enrichir et remixer** + +- **Wikimedia Commons**, **Openverse** (moteur de recherche de médias sous Creative Commons) +- **Freesound** pour les sons, ccMixter et Free Music Archive pour la musique +- Toujours vérifier la licence : une ressource en ND (pas de modification) ne peut pas être remixée + +**Droits** + +- Droit à l'image : autorisation des personnes filmées ou photographiées +- Droit d'auteur, courte citation, exception pédagogique +- Créditer les ressources utilisées dans le générique + +### Idées d'activités + +- Comparer l'agrandissement d'un logo en PNG dans GIMP et en SVG dans Inkscape +- Réaliser un podcast de 3 minutes avec Audacity, avec un générique musical sous licence libre +- Tourner et monter une courte vidéo dans Kdenlive, sous-titres et crédits compris +- Enregistrer un tutoriel vidéo avec OBS pour expliquer une manipulation sous Linux +- Créer un remix à partir de ressources trouvées sur Openverse, en documentant chaque licence +- Animation image par image avec Pencil2D diff --git a/docs/presentations/Pix orientée Libre/Développer des documents textuels.md b/docs/presentations/Pix orientée Libre/Développer des documents textuels.md new file mode 100644 index 0000000..faeefa0 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Développer des documents textuels.md @@ -0,0 +1,73 @@ +## 3.1 Développer des documents textuels + +**Ce que recouvre la compétence** : produire des documents principalement textuels pour communiquer des idées, rendre compte d'un travail ou le valoriser (traitement de texte, présentation, page web, carte mentale). Thématiques Pix : structuration et séparation fond/forme, mise en page, intégration d'illustrations, formats et diffusion, accessibilité, droit d'auteur. + +### Progression par niveaux (résumé) + +- **N1-N2** : saisir et mettre en forme un texte simple (gras, taille, alignement), insérer une image. +- **N3** : structurer un document avec des titres, insérer tableaux et listes, exporter en PDF. +- **N4-N5** : utiliser les **styles**, générer une table des matières, gérer en-têtes, pieds de page et notes, créer un modèle. +- **N6+** : séparer complètement fond et forme, automatiser (publipostage, conversion), produire des documents accessibles et interopérables. + +### Version Linux et libre + +**Traitement de texte : LibreOffice Writer** + +- **Styles** de paragraphe, de caractère, de page : le cœur de la compétence +- Numérotation automatique des titres, table des matières, index, notes de bas de page +- Ancrage et habillage des images, tableaux, sections en colonnes +- Modèles (`.ott`), publipostage +- Export PDF, PDF/A pour l'archivage, PDF balisé pour l'accessibilité +- **Vérificateur d'accessibilité** intégré + +**Correction de la langue** + +- **Grammalecte** : correcteur grammatical français libre, pour LibreOffice et Firefox + +**Présentations** + +- LibreOffice Impress : masques de diapositives, mode présentateur, export PDF + +**Écrire en Markdown** + +- Éditeurs : Apostrophe (GNOME), Ghostwriter (KDE), Zettlr, ou simplement Kate / Éditeur de texte +- **Pandoc** : convertir un même texte en ODT, DOCX, PDF, HTML ou EPUB, démonstration parfaite de la séparation fond/forme + +**Composition avancée** + +- LaTeX avec TeXstudio, pour les documents scientifiques +- **Typst** : alternative récente, libre, plus simple à apprendre +- Scribus pour la mise en page de type affiche, journal ou brochure + +**Cartes mentales et conceptuelles** + +- Freeplane, VYM, Heimer + +**Pages web et livres numériques** + +- HTML et CSS avec un simple éditeur de texte et le navigateur +- Sigil pour créer un EPUB, Calibre pour le convertir et le gérer + +**Formats et interopérabilité** + +- ODF (`.odt`) : format ouvert, normalisé ISO, recommandé par le Référentiel général d'interopérabilité de l'administration +- Échanges avec des utilisateurs de Word : polices aux métriques compatibles (Carlito pour Calibri, Caladea pour Cambria) pour éviter les décalages de mise en page + +**Typographie et polices libres** + +- Liberation, DejaVu, Noto +- **Luciole** : police libre conçue pour les personnes malvoyantes +- Installer une police dans `~/.local/share/fonts` + +**Accessibilité** + +- Titres structurés par styles (et non par mise en forme manuelle) +- Texte alternatif pour les images, contrastes suffisants, écriture FALC (Facile à lire et à comprendre) + +### Idées d'activités + +- Partir d'un document mis en forme « à la main » et le refaire entièrement avec les styles, puis changer tout son aspect en modifiant un seul style +- Rédiger un compte rendu en Markdown, puis le convertir avec Pandoc en ODT, PDF et page web +- Faire passer le vérificateur d'accessibilité sur un document et corriger toutes les alertes +- Échanger un fichier ODT et DOCX avec un utilisateur de Word et analyser ce qui change +- Créer un modèle de lettre réutilisable avec publipostage à partir d'un fichier Calc diff --git a/docs/presentations/Pix orientée Libre/Gérer des données.md b/docs/presentations/Pix orientée Libre/Gérer des données.md new file mode 100644 index 0000000..47c580a --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Gérer des données.md @@ -0,0 +1,75 @@ +## 1.2 Gérer des données + +**Ce que recouvre la compétence** : stocker et organiser ses fichiers pour les retrouver, les conserver et y accéder facilement, en local comme en ligne. Les thématiques Pix : fichiers et dossiers, stockage et compression, transfert et synchronisation, recherche et métadonnées, sauvegarde. + +### Progression par niveaux (résumé) + +- **N1-N2** : enregistrer un fichier, le retrouver, le nommer et le ranger dans un dossier. +- **N3** : construire une arborescence cohérente, utiliser un espace de stockage en ligne, compresser/décompresser. +- **N4-N5** : synchroniser plusieurs appareils, mettre en place des sauvegardes, choisir un format adapté, exploiter les métadonnées. +- **N6+** : définir une stratégie de stockage et de sauvegarde pour un groupe, gérer les droits d'accès. + +### Version Linux et libre + +**Comprendre l'arborescence** + +- Une seule racine `/`, le dossier personnel `/home/utilisateur`, les supports amovibles dans `/media` +- Chemins absolus et relatifs, fichiers cachés (commençant par un point) +- La casse compte : `Rapport.odt` ≠ `rapport.odt` +- Le type d'un fichier ne dépend pas de son extension : la commande `file` le révèle + +**Gestionnaires de fichiers** + +- Graphiques : Fichiers (GNOME), Dolphin (KDE), Thunar (XFCE), Nemo +- Terminal : `ls`, `cd`, `mkdir`, `cp`, `mv`, `rm`, `tree` ; Midnight Commander (`mc`) en intermédiaire +- Bonnes pratiques de nommage : dates au format `AAAA-MM-JJ`, pas d'espaces ni d'accents si les fichiers circulent + +**Retrouver ses fichiers** + +- `find`, `locate`, `grep -r` pour chercher dans le contenu +- Recherche intégrée au bureau : Tracker/LocalSearch (GNOME), Baloo (KDE) +- Doublons : Czkawka (graphique), `fdupes` + +**Métadonnées** + +- Étiquettes dans Dolphin, gestion de photothèque avec digiKam +- Lire : `exiftool` ; nettoyer avant partage : **mat2** (lien direct avec la compétence 4.2 sur la vie privée) + +**Compression et archives** + +- `tar`, `gzip`, `xz`, `zip`, `7z` +- Graphiques : Archives (File Roller), Ark + +**Supports et systèmes de fichiers** + +- Voir l'espace : `df -h`, `du`, `ncdu`, `lsblk` +- GNOME Disques, GParted pour formater +- Choisir le bon format : ext4 pour Linux, exFAT pour une clé partagée avec Windows/macOS + +**Stockage en ligne et synchronisation** + +- **Nextcloud** (auto-hébergé ou chez un CHATON), Seafile +- **Syncthing** : synchronisation pair à pair, sans serveur central +- `rsync` en ligne de commande +- Transfert ponctuel : LocalSend (réseau local), Lufi (partage chiffré temporaire, Framasoft), `scp`/`sftp`, FileZilla + +**Sauvegarde** + +- Règle 3-2-1 : 3 copies, 2 supports différents, 1 hors site +- Déjà Dup (simple, intégré à GNOME), Back In Time, BorgBackup avec son interface Vorta, restic +- Timeshift pour des instantanés du système (à distinguer de la sauvegarde des données personnelles) + +**Droits et partage** + +- `ls -l`, `chmod`, `chown` : lire et modifier les permissions, comprendre propriétaire/groupe/autres + +**Formats ouverts** (pour la conservation à long terme) + +- ODF (LibreOffice), CSV, Markdown, PNG, FLAC, PDF/A + +### Idées d'activités + +- Ranger un dossier « fourre-tout » en arborescence logique, d'abord graphiquement puis en ligne de commande +- Mettre en place une synchronisation Syncthing entre un PC et un téléphone +- Retirer les métadonnées GPS d'une photo avec mat2 et vérifier avec exiftool +- Simuler une panne : sauvegarder avec Déjà Dup, supprimer un fichier, le restaurer diff --git a/docs/presentations/Pix orientée Libre/Interagir.md b/docs/presentations/Pix orientée Libre/Interagir.md new file mode 100644 index 0000000..b1a1a35 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Interagir.md @@ -0,0 +1,55 @@ +## 2.1 Interagir + +**Ce que recouvre la compétence** : échanger avec des personnes ou de petits groupes, de façon ponctuelle ou régulière, dans un cadre privé ou professionnel, par courriel, messagerie instantanée ou visioconférence. Thématiques Pix : protocoles et services de communication, usages adaptés au contexte, savoir-vivre en ligne (nétiquette), confidentialité, prévention du cyberharcèlement. + +### Progression par niveaux (résumé) + +- **N1-N2** : envoyer et recevoir un message, répondre, joindre un fichier. +- **N3** : choisir l'outil adapté à la situation, utiliser À / Cc / Cci à bon escient, adopter un ton approprié. +- **N4-N5** : organiser des échanges de groupe, animer une visioconférence, gérer plusieurs comptes et identités. +- **N6+** : comprendre les protocoles et le chiffrement, choisir des services selon des critères de confidentialité, réagir face à des comportements inappropriés. + +### Version Linux et libre + +**Courriel** + +- **Thunderbird** (avec chiffrement OpenPGP intégré), Evolution, KMail, Geary +- En terminal : NeoMutt, aerc +- Webmails libres : Roundcube, SnappyMail +- Notions à faire comprendre : IMAP vs POP, SMTP, Cci pour protéger les adresses d'un groupe, taille des pièces jointes +- Fournisseurs soucieux de la vie privée : Disroot, Posteo, mailbox.org, Tuta et Proton (applications open source) + +**Messagerie instantanée** + +- **Matrix** avec le client Element : décentralisé, fédéré, chiffré de bout en bout +- **XMPP** : Gajim, Dino (bureau), Conversations (Android), Movim (web) +- Signal : application libre et chiffrée, mais serveur centralisé +- Delta Chat : messagerie instantanée qui s'appuie sur le courriel +- IRC (HexChat, WeeChat) pour découvrir l'historique des communautés libres +- Notion clé à travailler : centralisé, fédéré, pair à pair + +**Visioconférence et audio** + +- **Jitsi Meet** : sans compte, dans le navigateur +- **BigBlueButton** : classes virtuelles (proposé sur apps.education.fr pour les personnels de l'Éducation nationale) +- Nextcloud Talk, Mumble pour l'audio de groupe +- Côté Linux : tester webcam et micro (Snapshot, `pavucontrol`), comprendre le partage d'écran sous Wayland + +**Planifier les échanges** + +- Framadate pour trouver une date commune +- Agendas partagés via CalDAV : Nextcloud, agenda de Thunderbird + +**Savoir-vivre et sécurité** + +- Nétiquette : objet clair, pas de « répondre à tous » inutile, majuscules perçues comme des cris +- Cyberharcèlement : repérer, conserver des preuves, signaler (numéro 3018 pour les jeunes) +- Hameçonnage : vérifier l'expéditeur réel (lien avec le domaine 4) + +### Idées d'activités + +- Configurer un compte dans Thunderbird en IMAP, puis envoyer un message à un groupe en Cci +- Créer un salon Matrix pour la classe ou le groupe, et comparer avec un groupe WhatsApp (qui détient les données, qui peut lire) +- Organiser une réunion : sondage Framadate, puis séance sur Jitsi ou BigBlueButton avec partage d'écran +- Échanger un message chiffré OpenPGP entre deux participants dans Thunderbird +- Analyser des cas de messages problématiques (ton, destinataires, harcèlement) et proposer une réaction adaptée diff --git a/docs/presentations/Pix orientée Libre/Mener une recherche et une veille d'information.md b/docs/presentations/Pix orientée Libre/Mener une recherche et une veille d'information.md new file mode 100644 index 0000000..cd6f8a5 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Mener une recherche et une veille d'information.md @@ -0,0 +1,66 @@ +## 1.1 Mener une recherche et une veille d'information + +**Ce que recouvre la compétence** : trouver une information pour répondre à un besoin (moteur de recherche, sites, bases documentaires), évaluer sa fiabilité, et se tenir au courant d'un sujet dans la durée (abonnements, flux, réseaux). + +Pix la découpe en grandes thématiques : navigation web, moteurs de recherche, évaluation de l'information, source et citation, veille et curation, filtrage/personnalisation (bulles de filtre), gouvernance et ouverture du web. + +### Progression par niveaux (résumé) + +- **N1-N2** : faire une recherche simple, reformuler ses mots-clés, repérer l'auteur et la date d'une page. +- **N3** : utiliser les fonctions avancées des moteurs, croiser les sources, distinguer information, opinion et publicité. +- **N4-N5** : construire une stratégie de recherche, mettre en place une veille outillée (flux, alertes). +- **N6+** : organiser, trier et partager une veille (curation), comprendre les mécanismes des algorithmes de recommandation. + +### Version Linux et libre + +**Navigation** + +- Firefox (ou LibreWolf, sa variante orientée vie privée), Chromium +- Extensions : uBlock Origin (bloqueur), Privacy Badger +- En terminal : `lynx` ou `w3m` pour naviguer en texte, `curl`/`wget` pour récupérer une page + +**Moteurs de recherche** + +- **SearXNG** : métamoteur libre, auto-hébergeable, sans pistage (instances publiques disponibles) +- Alternatives respectueuses mais non libres : DuckDuckGo, Qwant, Mojeek, Startpage +- Bases ouvertes : Wikipédia, Wikidata, OpenStreetMap, Internet Archive, HAL (archive ouverte de la recherche française) +- Opérateurs à maîtriser : `"expression exacte"`, `-exclure`, `site:debian.org`, `filetype:pdf`, `OR` + +**Chercher dans la documentation Linux** (spécifique et très formateur) + +- `man`, `apropos`, `--help`, `tldr` +- Wikis communautaires : doc.ubuntu-fr.org, wiki Arch Linux, wiki Debian +- Forums et suivis de bugs comme sources de solutions (et leur fiabilité variable) + +**Veille par flux RSS/Atom** + +- Lecteurs locaux : Thunderbird, Liferea, Akregator (KDE), NewsFlash (GNOME), Newsboat (terminal) +- Auto-hébergés : **FreshRSS** (projet français), Miniflux, Tiny Tiny RSS +- Surveillance de pages sans flux : changedetection.io +- Réseaux décentralisés : Mastodon (fédivers), Lemmy, PeerTube, avec abonnement par hashtags + +**Curation et conservation** + +- **Wallabag** (lire plus tard, français), **Shaarli** (partage de liens, français), Linkding +- **Zotero** : collecte de références et génération de citations, idéal pour le volet « source et citation » +- **Joplin** (libre, AGPL) : notes Markdown, Web Clipper pour Firefox (page entière, sélection ou URL avec sa source), synchronisation Nextcloud/WebDAV chiffrée de bout en bout +- **Obsidian** : gratuit mais **pas libre** ; stocke tout en fichiers Markdown ordinaires, donc les données restent dans un format ouvert. Bon support pour distinguer _logiciel libre_ et _format ouvert_. Web Clipper pour Firefox, synchronisation possible avec Syncthing +- Alternatives 100 % libres à Obsidian (notes liées, graphe) : **Logseq** (AGPL), **Zettlr** (GPL, intégré à Zotero), **Trilium Notes** (auto-hébergeable) +- Côté pédagogie : ces outils couvrent les niveaux 5 et plus (organiser sa veille avec étiquettes et liens entre notes, garder la trace des sources) + +**Évaluer l'information** + +- Wayback Machine pour vérifier l'historique d'une page +- `whois` et `dig` pour savoir qui est derrière un domaine +- Métadonnées d'images : `exiftool` + +**Droit et ouverture du web** + +- Licences Creative Commons, GPL, et Wikimedia Commons pour des ressources réutilisables +- Repères militants et pédagogiques : Framasoft (et le collectif CHATONS d'hébergeurs alternatifs), La Quadrature du Net, l'April + +### Idées d'activités + +- Comparer les résultats d'une même requête sur SearXNG, DuckDuckGo et Google pour illustrer la bulle de filtre +- Résoudre un problème Linux réel uniquement avec `man` et le wiki ubuntu-fr +- Monter une veille sur un sujet avec FreshRSS ou Newsboat, puis en publier une sélection via Shaarli diff --git a/docs/presentations/Pix orientée Libre/Partager et publier.md b/docs/presentations/Pix orientée Libre/Partager et publier.md new file mode 100644 index 0000000..2c8f513 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Partager et publier.md @@ -0,0 +1,63 @@ +## 2.2 Partager et publier + +**Ce que recouvre la compétence** : diffuser ses productions ou ses opinions et relayer celles des autres dans un espace public (réseaux sociaux, blogs, forums, plateformes de partage). Thématiques Pix : règles de visibilité, identité numérique et e-réputation, droit d'auteur et licences, liberté d'expression et ses limites, écriture pour le web, participation citoyenne. + +### Progression par niveaux (résumé) + +- **N1-N2** : partager un fichier ou un lien, publier un message simple. +- **N3** : régler la visibilité d'une publication (public, restreint, privé), respecter le droit à l'image et le droit d'auteur. +- **N4-N5** : publier sur un blog ou un site, choisir une licence pour ses productions, maîtriser son identité numérique. +- **N6+** : animer un espace de publication, comprendre les mécanismes de diffusion et d'influence, contribuer à des communs. + +### Version Linux et libre + +**Le fédivers** (réseaux sociaux décentralisés, notion centrale à faire comprendre) + +- **Mastodon** : microblog, avec visibilité réglable (publique, non listée, abonnés, mention privée) +- **PeerTube** (vidéo, développé par Framasoft), **Pixelfed** (photo), **Lemmy** (forums thématiques), Funkwhale (audio) +- **WriteFreely** : blog minimaliste qui se suit depuis Mastodon +- Principe clé : choisir son instance, ses règles de modération, pouvoir déménager son compte + +**Blogs et sites** + +- **WordPress** (libre), Dotclear, PluXml (français, sans base de données) +- Sites statiques : Hugo, Jekyll, Zola, hébergés gratuitement via Codeberg Pages ou GitLab Pages +- Wikis : DokuWiki, MediaWiki +- Forums : Discourse, Flarum + +**Partage de fichiers et de contenus** + +- Liens de partage **Nextcloud** avec mot de passe et date d'expiration +- **CryptPad** : documents partagés chiffrés de bout en bout +- PrivateBin pour partager un texte de façon chiffrée et éphémère, Lufi pour les fichiers + +**Contribuer aux communs** (participation citoyenne) + +- Wikipédia, Wikimedia Commons, OpenStreetMap +- Signaler un bug ou traduire un logiciel libre + +**Droit et licences** + +- Droit d'auteur et droit à l'image : ce qu'on peut publier, ce qu'il faut demander +- Licences **Creative Commons** (BY, SA, NC, ND) : comprendre et choisir celle de sa propre production +- Citer correctement ce qu'on relaie +- Limites de la liberté d'expression : diffamation, injure, incitation à la haine + +**Identité numérique et e-réputation** + +- Faire sa « recherche de soi-même » sur plusieurs moteurs (lien avec 1.1) +- Droit au déréférencement et droit à l'effacement (RGPD, CNIL) +- Séparer identités personnelle et professionnelle + +**Écrire pour le web** + +- Markdown, titres clairs, liens explicites +- Accessibilité : texte alternatif pour les images (pratique très ancrée sur Mastodon), hashtags en `#CamelCase` pour les lecteurs d'écran + +### Idées d'activités + +- Créer un compte Mastodon, régler la visibilité de trois publications différentes et comparer qui peut les voir +- Publier un mini-site avec Hugo sur Codeberg Pages, sous licence Creative Commons choisie et justifiée +- Ajouter une photo personnelle à Wikimedia Commons ou corriger une rue dans OpenStreetMap +- Audit d'identité numérique : chercher son nom, repérer ce qui est public, faire le ménage +- Comparer les conditions d'utilisation d'Instagram et d'une instance Pixelfed : à qui appartiennent les contenus publiés ? diff --git a/docs/presentations/Pix orientée Libre/Programmer.md b/docs/presentations/Pix orientée Libre/Programmer.md new file mode 100644 index 0000000..e1d3c9f --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Programmer.md @@ -0,0 +1,74 @@ +## 3.4 Programmer + +**Ce que recouvre la compétence** : écrire des programmes et des algorithmes pour répondre à un besoin (automatiser une tâche répétitive, résoudre un problème, traiter un volume important) ou pour créer un contenu riche comme un jeu ou un site. Thématiques Pix : algorithmique (séquences, boucles, conditions, variables, fonctions), langages, automatisation, débogage, programmation web. + +### Progression par niveaux (résumé) + +- **N1-N2** : lire et comprendre un algorithme simple, programmer des séquences d'instructions par blocs. +- **N3** : utiliser boucles, conditions et variables, corriger un programme qui ne fonctionne pas. +- **N4-N5** : passer à un langage textuel, structurer avec des fonctions, automatiser une tâche réelle. +- **N6+** : concevoir un programme complet, gérer les erreurs, réutiliser des bibliothèques, planifier l'exécution automatique de tâches. + +### Version Linux et libre + +**Programmation par blocs** + +- **Snap!** : libre, dans le navigateur, plus puissant que Scratch +- **TurboWarp** : version libre et rapide compatible Scratch, avec application de bureau Linux +- Blockly Games : progression ludique vers la logique de programmation + +**Python, le passage naturel au code textuel** + +- **Thonny** : conçu pour débuter, avec exécution pas à pas pour visualiser les variables +- **Basthon** : Python et notebooks dans le navigateur, pensé pour l'enseignement en France +- JupyterLab pour mêler code, texte et graphiques +- Module `turtle` pour dessiner, Pyxel ou Pygame pour créer des jeux + +**Automatiser sous Linux** (l'atout majeur de l'environnement) + +- Scripts **Bash** : boucles sur des fichiers, renommage par lots, sauvegardes +- Exemple parlant : `for f in *.jpg; do magick "$f" -resize 800x "petit-$f"; done` +- Planifier des tâches : **cron** ou les minuteurs systemd, ce qui répond directement à la mention des « logiciels de planification de tâches » dans le référentiel +- Alias et petits scripts dans `~/bin` pour se créer ses propres commandes +- Macros LibreOffice en Basic ou en Python + +**Éditeurs et environnements** + +- Geany, Kate : simples et légers +- **VSCodium** : version libre de VS Code, sans télémétrie +- nano pour débuter en terminal, Vim ou Emacs pour aller loin + +**Web** + +- HTML, CSS, JavaScript avec un éditeur et Firefox +- Outils de développement de Firefox (F12) : inspecter, modifier une page en direct, lire les erreurs +- **p5.js** : programmation créative et visuelle + +**Jeux** + +- **Godot** : moteur de jeu libre complet, 2D et 3D +- GDevelop : création de jeux sans coder ou presque + +**Robotique et électronique** + +- **Raspberry Pi** : ordinateur sous Linux pour piloter capteurs et moteurs +- Arduino IDE, MicroPython, micro:bit +- Thymio : petit robot éducatif avec environnement libre + +**La culture du libre** + +- Lire le code source d'un logiciel qu'on utilise +- Versionner son travail avec Git et le publier sur Codeberg (lien 2.3) + +**Débogage** + +- Lire un message d'erreur, afficher des valeurs intermédiaires, exécuter pas à pas, tester avec des cas limites + +### Idées d'activités + +- Débuter avec Snap!, puis réécrire le même programme en Python dans Thonny +- Écrire un script Bash qui trie un dossier de téléchargements par type de fichier, puis le planifier chaque soir avec cron +- Programmer un petit jeu avec Pyxel ou Godot +- Modifier une page web en direct avec les outils de développement de Firefox, puis créer la sienne +- Piloter une LED ou un capteur avec un Raspberry Pi +- Modifier un petit logiciel libre existant (changer un texte, une couleur) pour comprendre ce que signifie avoir accès au code diff --git a/docs/presentations/Pix orientée Libre/Protéger la santé, le bien-être et l'environnement.md b/docs/presentations/Pix orientée Libre/Protéger la santé, le bien-être et l'environnement.md new file mode 100644 index 0000000..c1155c5 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Protéger la santé, le bien-être et l'environnement.md @@ -0,0 +1,57 @@ +## 4.3 Protéger la santé, le bien-être et l'environnement + +**Ce que recouvre la compétence** : prévenir les effets négatifs du numérique sur la santé physique et psychique et sur l'environnement, mais aussi tirer parti du numérique pour le bien-être, l'inclusion et l'accessibilité. Thématiques Pix : ergonomie et santé physique, sommeil et attention, dépendance et harcèlement, impact environnemental, accessibilité. + +### Progression par niveaux (résumé) + +- **N1-N2** : adopter une bonne posture, faire des pauses, limiter les écrans le soir. +- **N3** : régler son poste pour son confort, gérer ses notifications, connaître les gestes de base pour l'environnement (recyclage). +- **N4-N5** : maîtriser son temps d'écran, utiliser les outils d'accessibilité, comprendre que l'essentiel de l'impact environnemental vient de la fabrication des appareils. +- **N6+** : adopter une démarche de sobriété numérique argumentée, concevoir des contenus accessibles et peu gourmands, accompagner les autres. + +### Version Linux et libre + +**Santé physique et ergonomie** + +- Éclairage nocturne intégré à GNOME et KDE (réduction de la lumière bleue le soir) +- Rappels de pause : **Safe Eyes**, Workrave, Stretchly ; règle 20-20-20 pour les yeux +- Raccourcis clavier pour limiter l'usage de la souris et les troubles musculo-squelettiques +- Réglages de la taille du texte, du zoom, de la vitesse du pointeur + +**Attention et bien-être psychique** + +- Mode « Ne pas déranger » et réglage fin des notifications +- Les versions récentes de GNOME intègrent un suivi du temps d'écran, des limites et des rappels de pause +- **LeechBlock NG** (Firefox) : bloquer les sites chronophages à certaines heures +- **FreeTube** (bureau) et NewPipe (Android) : regarder des vidéos sans recommandations algorithmiques ni publicité +- Fils chronologiques sans algorithme (Mastodon) vs fils conçus pour retenir l'attention +- Contrôle parental : Malcontent (GNOME), **CTparental** (libre, français) +- Cyberharcèlement et mal-être : 3018, Fil Santé Jeunes (lien 2.1) + +**Impact environnemental** + +- Ordre de grandeur essentiel : la fabrication des appareils pèse bien plus que leur usage, d'où l'enjeu de les garder longtemps +- **Linux pour prolonger la vie du matériel** : distributions légères (Xubuntu, Lubuntu, Linux Mint XFCE, antiX), Emmabuntüs pour le réemploi, campagne _End of 10_ +- Réparabilité : indice de réparabilité/durabilité, appareils reconditionnés, recyclage des équipements électriques (DEEE) +- Consommation : TLP et les profils d'énergie pour l'autonomie des portables, `powertop` pour repérer ce qui consomme +- Mesurer l'empreinte d'une page web : extension **GreenIT-Analysis**, EcoIndex (outils libres français) +- Sobriété des usages : résolution vidéo adaptée, téléchargement plutôt que streaming répété, en gardant le sens des proportions (trier ses courriels a un effet très faible comparé au fait de garder son ordinateur deux ans de plus) +- Référentiel général d'écoconception de services numériques (RGESN) + +**Accessibilité et inclusion** + +- **Orca** : lecteur d'écran +- Loupe, contraste élevé, grands caractères, curseur agrandi dans les paramètres d'accessibilité +- Clavier visuel (Onboard), Dasher pour écrire sans clavier +- Synthèse vocale : espeak-ng, Piper (voix plus naturelles) +- Dictée vocale hors ligne avec des outils basés sur Whisper (Speech Note) +- Police Luciole, écriture FALC, documents accessibles (lien 3.1) +- RGAA : référentiel d'accessibilité des services publics en ligne + +### Idées d'activités + +- Installer une distribution légère sur un vieil ordinateur et comparer son usage avant et après +- Mesurer avec GreenIT-Analysis plusieurs sites d'information et classer leur sobriété +- Tenir pendant une semaine un journal de son temps d'écran, puis mettre en place une règle avec LeechBlock ou les outils de GNOME +- Utiliser l'ordinateur pendant 15 minutes uniquement avec Orca, écran éteint, pour comprendre les enjeux de l'accessibilité +- Calculer l'empreinte de son équipement avec le simulateur Impact CO2 de l'ADEME et discuter des leviers réels diff --git a/docs/presentations/Pix orientée Libre/Protéger les données personnelles et la vie privée.md b/docs/presentations/Pix orientée Libre/Protéger les données personnelles et la vie privée.md new file mode 100644 index 0000000..c2bba37 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Protéger les données personnelles et la vie privée.md @@ -0,0 +1,75 @@ +## 4.2 Protéger les données personnelles et la vie privée + +**Ce que recouvre la compétence** : maîtriser ses traces numériques, gérer ses données personnelles et respecter celles des autres, en adoptant des pratiques éclairées. Là où la 4.1 protège contre les attaques, la 4.2 protège contre la collecte et l'exploitation des données, souvent parfaitement légales. Thématiques Pix : données personnelles et sensibles, traces (cookies, historique, métadonnées, géolocalisation), pistage et profilage, paramètres de confidentialité, RGPD, respect de la vie privée d'autrui. + +### Progression par niveaux (résumé) + +- **N1-N2** : savoir ce qu'est une donnée personnelle, ne pas diffuser d'informations sur les autres sans accord. +- **N3** : effacer son historique, régler les paramètres de confidentialité d'un service, comprendre les cookies. +- **N4-N5** : limiter le pistage, contrôler ses traces (métadonnées, géolocalisation), exercer ses droits RGPD. +- **N6+** : comprendre le profilage et l'économie des données, choisir des services selon leurs pratiques, conseiller les autres. + +### Version Linux et libre + +**Comprendre les traces** + +- Cookies, pixels espions, empreinte du navigateur (_fingerprinting_) +- **CookieViz** : outil libre de la CNIL qui montre en direct les échanges avec les pisteurs pendant la navigation +- Firefox : page `about:protections` qui récapitule les pisteurs bloqués +- Navigation privée : ce qu'elle efface localement, et ce qu'elle ne cache pas (fournisseur d'accès, sites visités) + +**Navigateur protecteur** + +- Firefox en protection renforcée (mode strict) +- **Conteneurs** (Firefox Multi-Account Containers) : isoler ses comptes les uns des autres +- uBlock Origin, Privacy Badger +- LibreWolf, Mullvad Browser, Tor Browser selon le niveau de protection voulu +- Moteurs de recherche sans profilage (lien 1.1) + +**Les traces sur son propre poste** + +- Paramètres de confidentialité de GNOME ou KDE : localisation, historique des fichiers récents, accès caméra et micro +- Historique du terminal (`~/.bash_history`) +- Télémétrie : la plupart des distributions n'en ont pas ou la proposent en option, un point de comparaison intéressant avec les systèmes commerciaux + +**Métadonnées** + +- Coordonnées GPS dans les photos, auteur dans les documents +- `exiftool` pour les voir, **mat2** pour les nettoyer (lien 1.2 et 3.3) + +**Téléphone** + +- **Exodus Privacy** (association française) : liste les pisteurs présents dans les applications Android +- **F-Droid** : magasin d'applications libres +- Systèmes alternatifs : /e/OS, LineageOS, GrapheneOS +- Revoir les permissions des applications (localisation, contacts, micro) + +**Limiter les données transmises** + +- Principe de minimisation : ne donner que le nécessaire +- Alias de courriel pour les inscriptions : addy.io, SimpleLogin +- Fermer les comptes inutilisés +- Préférer des services hébergés chez un CHATON ou auto-hébergés + +**RGPD et droits** + +- Droits d'accès, de rectification, d'effacement, d'opposition, de portabilité +- Modèles de courriers sur le site de la **CNIL**, plainte en cas de refus +- Exporter ses données (par exemple Google Takeout) pour constater ce qui est conservé +- Données sensibles : santé, opinions, religion, orientation sexuelle + +**Respecter la vie privée des autres** + +- Droit à l'image, ne pas publier d'informations sur un tiers +- Ne pas diffuser de captures de conversations privées +- Applications qui aspirent tout le carnet d'adresses +- Cci pour les envois groupés (lien 2.1) + +### Idées d'activités + +- Naviguer 10 minutes sur des sites d'actualité avec CookieViz et analyser vers où partent les données +- Passer ses applications Android dans Exodus Privacy et en remplacer une par une alternative de F-Droid +- Exercer réellement son droit d'accès auprès d'un service et analyser la réponse +- Télécharger son export Google ou Facebook et découvrir ce qui est conservé +- Configurer Firefox avec des conteneurs séparés pour les réseaux sociaux, les achats et le reste +- Retirer la localisation de photos avant de les publier diff --git a/docs/presentations/Pix orientée Libre/Résoudre des problèmes techniques.md b/docs/presentations/Pix orientée Libre/Résoudre des problèmes techniques.md new file mode 100644 index 0000000..92a05a3 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Résoudre des problèmes techniques.md @@ -0,0 +1,91 @@ +## 5.1 Résoudre des problèmes techniques + +**Ce que recouvre la compétence** : diagnostiquer et résoudre des problèmes pour garantir ou rétablir le bon fonctionnement d'un environnement informatique, en utilisant les outils de configuration et de maintenance, et en sachant mobiliser de l'aide. Thématiques Pix : composants matériels et logiciels, diagnostic, maintenance, configuration, réseau, assistance. + +### Progression par niveaux (résumé) + +- **N1-N2** : redémarrer un appareil ou une application, vérifier les branchements et la connexion. +- **N3** : identifier l'origine probable d'une panne (matériel, logiciel, réseau), consulter une aide en ligne, faire les mises à jour. +- **N4-N5** : diagnostiquer avec les outils du système, installer ou réinstaller un logiciel ou un pilote, décrire précisément un problème pour obtenir de l'aide. +- **N6+** : analyser des journaux système, dépanner un système qui ne démarre plus, récupérer des données, assister d'autres personnes. + +### Version Linux et libre + +**Une méthode avant les outils** + +- Décrire précisément le symptôme, le reproduire +- Noter ce qui a changé récemment (mise à jour, installation, nouveau matériel) +- Isoler la cause, ne modifier qu'une chose à la fois, garder trace de ce qu'on a essayé +- Chercher le message d'erreur exact (lien 1.1) + +**Connaître sa machine** + +- `inxi -Fxz` : résumé complet du matériel, idéal à coller dans un forum +- `cat /etc/os-release`, `uname -r` : distribution et noyau +- `lspci`, `lsusb`, `lsblk`, `free -h`, `df -h` +- Interfaces graphiques : À propos (GNOME), Centre d'informations (KDE), Hardinfo2 + +**Programmes et ressources** + +- Moniteur système, `htop` ou `btop` : qui consomme le processeur ou la mémoire +- Arrêter un programme bloqué : `kill`, `xkill` +- Interface graphique figée : basculer vers une console avec Ctrl+Alt+F3 + +**Lire les journaux** + +- `journalctl -b -p err` : erreurs depuis le dernier démarrage +- `dmesg` : messages du noyau (matériel, pilotes) +- Application Journaux (GNOME) pour une lecture graphique + +**Réseau** + +- `ping`, `ip a`, `nmcli`, `mtr` : distinguer un problème de câble, de Wi-Fi, de box ou de DNS +- Pilotes Wi-Fi propriétaires (outil « Pilotes additionnels » sur Ubuntu) + +**Logiciels et mises à jour** + +- `sudo apt update && sudo apt upgrade`, ou `dnf upgrade` +- Réparer une installation interrompue : `sudo dpkg --configure -a`, `sudo apt --fix-broken install` +- Disque plein : `ncdu`, nettoyage du cache des paquets, `flatpak uninstall --unused` + +**Périphériques** + +- Imprimantes : CUPS (interface sur `localhost:631`) +- Scanner : Numériseur de documents (Simple Scan), SANE +- Son : `pavucontrol`, `wpctl status` (PipeWire) +- Bluetooth : `bluetoothctl` +- Vérifier la compatibilité d'un matériel avant achat : base linux-hardware.org + +**Quand le système ne démarre plus** + +- Menu GRUB : démarrer sur un ancien noyau ou en mode de récupération +- **Clé USB live** (Ventoy pour en avoir plusieurs sur une seule clé) pour accéder aux fichiers et réparer +- Boot-Repair pour réparer le chargeur de démarrage +- Timeshift pour revenir à un état antérieur du système + +**Matériel et données** + +- Santé des disques : GNOME Disques (SMART), `smartctl` +- Test de la mémoire vive : Memtest86+ +- **TestDisk** et **PhotoRec** : récupérer des partitions ou des fichiers supprimés + +**Applications Windows indispensables** + +- Wine et Bottles pour certaines applications +- Machine virtuelle avec GNOME Boxes ou virt-manager + +**Demander et donner de l'aide** + +- Forums : ubuntu-fr, forums de sa distribution, LinuxFr.org, salons Matrix ou IRC, groupes d'utilisateurs locaux +- Bien poser une question : distribution et version, matériel, message d'erreur exact, ce qui a déjà été tenté +- Prudence avec les commandes copiées sans les comprendre (lien 4.1) +- Assistance à distance : **RustDesk**, SSH +- Signaler un bug au projet concerné + +### Idées d'activités + +- Pannes simulées : imprimante en pause, Wi-Fi désactivé, DNS mal configuré, disque plein ; chacun diagnostique et documente +- Rédiger une demande d'aide modèle sur un forum à partir d'un problème réel, avec la sortie d'`inxi` +- Récupérer des photos « effacées » d'une clé USB avec PhotoRec +- Démarrer un ordinateur depuis une clé live et sauver des fichiers d'un système qui ne démarre plus +- Lire `journalctl` après avoir débranché un périphérique pour apprendre à repérer l'information utile diff --git a/docs/presentations/Pix orientée Libre/S'insérer dans le monde numérique.md b/docs/presentations/Pix orientée Libre/S'insérer dans le monde numérique.md new file mode 100644 index 0000000..e8613be --- /dev/null +++ b/docs/presentations/Pix orientée Libre/S'insérer dans le monde numérique.md @@ -0,0 +1,63 @@ +## 2.4 S'insérer dans le monde numérique + +**Ce que recouvre la compétence** : comprendre les enjeux de sa présence en ligne et choisir ses pratiques pour agir en tant que citoyen, acteur économique et professionnel dans le monde numérique. C'est la compétence la plus « culture générale » du référentiel. Thématiques Pix : identité et présence en ligne, modèles économiques du numérique, démarches administratives, vie professionnelle, participation citoyenne, questions éthiques et sociétales. + +### Progression par niveaux (résumé) + +- **N1-N2** : réaliser une démarche en ligne simple, savoir que ses actions laissent des traces. +- **N3** : comprendre qu'un service gratuit a un modèle économique, gérer sa présence sur quelques services. +- **N4-N5** : construire une présence en ligne cohérente avec ses objectifs (emploi, engagement), effectuer des démarches administratives complexes. +- **N6+** : analyser les enjeux économiques, politiques et éthiques du numérique, faire des choix argumentés et s'engager. + +### Version Linux et libre + +**Comprendre les modèles économiques** + +- « Si c'est gratuit, c'est vous le produit » : publicité ciblée, revente de données, économie de l'attention +- Freemium, abonnement, achats intégrés +- Le modèle du libre : dons, services, contributions d'entreprises, financements publics +- Les **4 libertés** du logiciel libre : utiliser, étudier, modifier, redistribuer + +**Enjeux de société** + +- Dépendance aux grandes plateformes, souveraineté numérique, interopérabilité +- Les communs numériques : Wikipédia, OpenStreetMap, logiciels libres +- Algorithmes de recommandation vs fils chronologiques (comme sur Mastodon) +- Interfaces trompeuses (_dark patterns_), notifications et captation de l'attention + +**Démarches en ligne depuis Linux** + +- FranceConnect, impots.gouv, ameli, ANTS fonctionnent dans Firefox +- Repérer les rares cas où un service impose un logiciel ou un système propriétaire, et savoir le signaler + +**Obsolescence et inclusion** + +- Redonner vie à un ancien ordinateur sous Linux (distributions légères, Emmabuntüs conçue pour le réemploi) +- Fin de support de Windows 10 en 2025 : la campagne _End of 10_ propose de passer à Linux plutôt que de jeter son PC +- Illectronisme : lieux de médiation numérique, conseillers numériques + +**Vie professionnelle** + +- CV et lettres avec LibreOffice, export PDF +- Contributions à des projets libres (code, traduction, documentation) comme vitrine de compétences +- Séparer présence professionnelle et vie privée + +**Participation citoyenne** + +- Plateformes participatives libres comme **Decidim**, utilisées par des collectivités pour les budgets participatifs et consultations +- Consultations publiques en ligne, pétitions +- Contributions aux communs (lien avec 2.2) + +**S'engager dans le libre** + +- Associations : **April**, **Framasoft**, **La Quadrature du Net**, FSFE, Wikimédia France, OpenStreetMap France +- Collectif **CHATONS** : hébergeurs alternatifs, éthiques et locaux +- Groupes d'utilisateurs Linux (GUL), _install parties_, événements comme les JDLL ou le Capitole du Libre + +### Idées d'activités + +- Lire ensemble les conditions d'utilisation d'un réseau social et identifier comment il gagne de l'argent +- Débat argumenté : « Une administration devrait-elle utiliser exclusivement des logiciels libres ? » +- Organiser une mini _install party_ : installer Linux sur un ordinateur destiné à être jeté +- Réaliser une démarche administrative complète sous Linux et noter les éventuels obstacles +- Explorer une consultation Decidim d'une collectivité et y analyser la participation diff --git a/docs/presentations/Pix orientée Libre/Sécuriser l'environnement numérique.md b/docs/presentations/Pix orientée Libre/Sécuriser l'environnement numérique.md new file mode 100644 index 0000000..a45c74c --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Sécuriser l'environnement numérique.md @@ -0,0 +1,73 @@ +## 4.1 Sécuriser l'environnement numérique + +**Ce que recouvre la compétence** : protéger ses équipements, ses communications et ses données contre les attaques, pièges et incidents (logiciels malveillants, hameçonnage, vol de compte, perte de données). Thématiques Pix : menaces et ingénierie sociale, authentification, chiffrement, mises à jour et logiciels de protection, sécurité des réseaux, paiements en ligne. + +### Progression par niveaux (résumé) + +- **N1-N2** : verrouiller son poste, ne pas partager ses mots de passe, reconnaître un message suspect évident. +- **N3** : créer des mots de passe robustes, faire les mises à jour, repérer une connexion sécurisée (HTTPS). +- **N4-N5** : utiliser un gestionnaire de mots de passe et la double authentification, chiffrer des données, sécuriser son réseau. +- **N6+** : comprendre les mécanismes d'attaque et de chiffrement, appliquer une politique de sécurité pour un groupe, réagir à un incident. + +### Version Linux et libre + +**Le modèle de sécurité de Linux** + +- Compte utilisateur sans privilèges, `sudo` pour les actions d'administration +- Logiciels installés depuis des dépôts officiels signés plutôt que téléchargés au hasard +- Mises à jour centralisées pour tout le système (gestionnaire de logiciels, `apt`, `dnf`), automatisables +- Flatpak : applications isolées, permissions réglables avec **Flatseal** +- AppArmor ou SELinux en arrière-plan +- À démystifier : Linux n'est pas invulnérable. L'hameçonnage, les extensions de navigateur malveillantes, un `curl … | bash` aveugle ou un dépôt tiers douteux restent des risques réels + +**Mots de passe et authentification** + +- **KeePassXC** : gestionnaire libre, certifié par l'ANSSI +- Bitwarden (serveur auto-hébergeable avec Vaultwarden) +- Phrases de passe (méthode Diceware) plutôt que mots de passe complexes impossibles à retenir +- Vérifier si ses comptes ont fuité : Have I Been Pwned, Mozilla Monitor +- Double authentification (TOTP) : KeePassXC lui-même, **Aegis** (Android), Authenticator (GNOME) +- Clés de sécurité physiques FIDO2, dont **Nitrokey** au matériel ouvert ; passkeys + +**Chiffrement** + +- **LUKS** : chiffrement complet du disque proposé à l'installation de la plupart des distributions +- VeraCrypt : conteneurs chiffrés, lisibles aussi sous Windows et macOS +- **Cryptomator** : chiffrer ses fichiers avant de les mettre dans le nuage +- GnuPG pour les fichiers et courriels (Kleopatra, ou Mots de passe et clés sous GNOME) +- Clés SSH pour se connecter à un serveur +- HTTPS et certificats : ce que garantit vraiment le cadenas, et ce qu'il ne garantit pas + +**Réseau** + +- Pare-feu : `ufw` et son interface **Gufw**, ou firewalld +- Wi-Fi : WPA2/WPA3, prudence sur les réseaux publics +- VPN : **WireGuard**, OpenVPN ; ce qu'un VPN protège et ses limites +- **Tor Browser** pour l'anonymat +- Firefox : mode HTTPS uniquement, DNS chiffré + +**Logiciels de protection** + +- ClamAV avec l'interface ClamTk : utile surtout pour analyser des fichiers destinés à des postes Windows ou des clés USB partagées +- uBlock Origin contre les publicités piégées + +**Hameçonnage et ingénierie sociale** + +- Vérifier l'adresse réelle d'un lien et l'expéditeur réel (en-têtes dans Thunderbird) +- Signaler : Signal Spam, **cybermalveillance.gouv.fr**, 17Cyber pour les victimes +- Rançongiciels : la meilleure protection reste la sauvegarde hors ligne (lien 1.2) + +**Bonnes pratiques matérielles** + +- Verrouiller sa session (Super + L), mot de passe du BIOS/UEFI +- Ne pas brancher une clé USB inconnue +- Référence : guide d'hygiène informatique de l'ANSSI + +### Idées d'activités + +- Migrer tous ses mots de passe vers KeePassXC et activer la double authentification sur un compte important +- Créer un conteneur VeraCrypt sur une clé USB et l'ouvrir sous un autre système +- Atelier hameçonnage : trier une série de vrais et faux messages en justifiant chaque choix +- Configurer Gufw et vérifier les ports ouverts avec `ss -tulpn` +- Examiner les permissions de ses applications Flatpak avec Flatseal et en restreindre une +- Échanger des fichiers chiffrés avec GnuPG entre deux participants diff --git a/docs/presentations/Pix orientée Libre/Traiter des données.md b/docs/presentations/Pix orientée Libre/Traiter des données.md new file mode 100644 index 0000000..4692772 --- /dev/null +++ b/docs/presentations/Pix orientée Libre/Traiter des données.md @@ -0,0 +1,66 @@ +## 1.3 Traiter des données + +**Ce que recouvre la compétence** : appliquer des traitements à des données pour les analyser et les interpréter, avec un tableur, une base de données, un programme ou un outil d'enquête. Thématiques Pix : structure des données, formules et calculs, tri et filtrage, représentation graphique, interprétation critique, formats de données. + +### Progression par niveaux (résumé) + +- **N1-N2** : saisir des données dans un tableau, faire une somme ou une moyenne simple, trier une colonne. +- **N3** : écrire des formules, utiliser références relatives et absolues, filtrer, produire un graphique adapté. +- **N4-N5** : croiser des données (tableaux croisés dynamiques), importer/exporter des formats comme le CSV, mener une enquête et en analyser les résultats. +- **N6+** : interroger une base de données, automatiser un traitement, détecter un biais ou une représentation trompeuse. + +### Version Linux et libre + +**Tableur** + +- **LibreOffice Calc** : formules, `$A$1` vs `A1`, tri, filtre automatique, validation de données, tableaux croisés dynamiques, graphiques +- Gnumeric : plus léger, réputé pour la justesse de ses fonctions statistiques +- **Grist** : entre tableur et base de données, libre, utilisé par l'administration française (auto-hébergeable) + +**Formats de données** + +- CSV : le piège classique de l'encodage (UTF-8) et du séparateur (`;` en France à cause de la virgule décimale) +- JSON, XML, ODS ; comprendre qu'un même jeu de données peut circuler sous plusieurs formes + +**Bases de données** + +- **LibreOffice Base** : tables, relations, formulaires, requêtes +- **SQLite** avec DB Browser for SQLite : idéal pour découvrir le SQL (`SELECT`, `WHERE`, `GROUP BY`) +- PostgreSQL ou MariaDB pour aller plus loin + +**Nettoyage de données** + +- **OpenRefine** : dédoublonner, harmoniser des valeurs, corriger des incohérences + +**En ligne de commande** (spécificité Linux très parlante) + +- `wc`, `sort`, `uniq -c`, `cut`, `grep`, `awk` : compter et agréger un fichier texte en une ligne +- `jq` pour le JSON, csvkit ou Miller (`mlr`) pour le CSV + +**Programmation et analyse** + +- Python avec pandas et matplotlib, dans **JupyterLab** +- R pour les statistiques +- gnuplot pour des graphiques scriptés + +**Enquêtes** + +- **LimeSurvey** : questionnaires et export des résultats vers Calc ou CSV + +**Données ouvertes et cartographie** + +- data.gouv.fr, INSEE, OpenStreetMap comme sources réelles +- **QGIS** pour traiter des données géographiques + +**Interprétation critique** + +- Moyenne vs médiane, corrélation vs causalité +- Repérer un graphique trompeur : axe tronqué, échelle inadaptée, camembert surchargé + +### Idées d'activités + +- Télécharger un jeu de données sur data.gouv.fr, résoudre le problème d'import CSV dans Calc, puis produire un graphique +- Comparer le même calcul fait dans Calc, en SQL sur SQLite et en une ligne de shell +- Nettoyer un fichier volontairement « sale » avec OpenRefine +- Créer un questionnaire LimeSurvey dans le groupe, puis analyser les réponses avec un tableau croisé dynamique +- Corriger un graphique trompeur trouvé dans la presse diff --git a/docs/presentations/Python/00-installation-environnement.md b/docs/presentations/Python/00-installation-environnement.md new file mode 100644 index 0000000..2f3cc1e --- /dev/null +++ b/docs/presentations/Python/00-installation-environnement.md @@ -0,0 +1,932 @@ +# Chapitre 00 — Installer Python et son environnement de travail + +> **⏱️ Durée estimée :** 2 à 3 heures (installation comprise) +> +> **📋 Prérequis :** aucun ! Savoir allumer un ordinateur, télécharger un fichier et taper au clavier suffit. Pense à jeter un œil au [sommaire](README.md) pour avoir une vue d'ensemble du cours. +> +> **🎯 Objectifs — À la fin de ce chapitre, tu sauras :** +> - installer la dernière version stable de Python 3 sur Windows, macOS ou Linux, et vérifier qu'elle fonctionne ; +> - installer et configurer VS Code avec l'extension Python ; +> - te déplacer dans tes dossiers avec le terminal (`cd`, `ls`, `dir`) ; +> - utiliser l'interpréteur interactif de Python comme une calculatrice améliorée ; +> - écrire, enregistrer et exécuter ton premier script, avec des commentaires ; +> - lire un message d'erreur de Python sans paniquer et trouver la ligne fautive. + +--- + +## Cours + +### 1. Python, c'est quoi exactement ? + +Un ordinateur est très rapide mais complètement bête : il ne fait que ce qu'on lui dit, à la lettre. Pour lui donner des ordres, on écrit des **programmes** dans un **langage de programmation**. Python est l'un de ces langages, et c'est sans doute le plus apprécié des débutants, parce qu'il se lit presque comme de l'anglais simplifié. + +Voici un programme Python complet : + +```python +print("Bonjour, le monde !") +``` + +Sortie : + +```text +Bonjour, le monde ! +``` + +Une seule ligne, et c'est tout. Dans d'autres langages, il faudrait plusieurs lignes de « cérémonie » pour obtenir le même résultat. + +Python est utilisé partout : automatisation de tâches répétitives, sites web, analyse de données, intelligence artificielle, jeux, robotique… Tu découvriras ces domaines au [chapitre 14](14-specialisations.md). + +**Comment ça marche ?** Le texte que tu écris (le **code source**) est lu par un programme appelé **l'interpréteur Python**. L'interpréteur lit ton code ligne par ligne, de haut en bas, et exécute chaque instruction. Pense à un cuisinier (l'interpréteur) qui lit une recette (ton code) : il suit les étapes dans l'ordre, et s'il tombe sur une phrase incompréhensible, il s'arrête et te le dit. + +Installer Python, c'est donc installer cet interpréteur sur ton ordinateur. + +### 2. Installer Python + +> 💡 Installe toujours **la dernière version stable de Python 3**, disponible sur le site officiel [python.org](https://www.python.org/downloads/). Ce cours demande au minimum Python 3.12. N'installe jamais « Python 2 » : il est abandonné depuis 2020. + +Choisis la section qui correspond à ton système. + +#### 2.1. Sous Windows + +Il existe aujourd'hui deux façons officielles d'installer Python sous Windows. Les deux fonctionnent ; choisis-en **une seule**. + +**Méthode A — L'installateur classique (le plus courant dans les tutoriels)** + +1. Va sur [python.org/downloads](https://www.python.org/downloads/) et clique sur le gros bouton jaune *Download Python 3.x.x*. Si le site te propose plutôt le « Python install manager », regarde la méthode B. +2. Lance le fichier `.exe` téléchargé. +3. ⚠️ **ÉTAPE CRUCIALE** : tout en bas de la première fenêtre, **coche la case « Add python.exe to PATH »**. Si tu l'oublies, la commande `python` ne sera pas reconnue dans le terminal. +4. Clique sur *Install Now* et attends la fin de l'installation. +5. Si un message *Disable path length limit* apparaît à la fin, clique dessus (c'est sans risque et ça évite des soucis plus tard). + +> 🤔 **Le PATH, c'est quoi ?** C'est une liste de dossiers dans lesquels Windows cherche les programmes quand tu tapes une commande. Cocher la case ajoute le dossier de Python à cette liste. Sans ça, Windows te répond qu'il ne connaît pas `python`, un peu comme si tu cherchais un livre dans une bibliothèque qui n'a pas de fiche à son nom. + +**Méthode B — Le « Python install manager »** + +Les versions récentes de Python proposent sous Windows un **gestionnaire d'installation** (Python install manager). Il est disponible dans le **Microsoft Store** (cherche « Python install manager ») ou sur python.org. Une fois installé, il fournit les commandes `python` et `py` : la première fois que tu tapes `python` dans un terminal, il télécharge et installe automatiquement la dernière version stable. Si on te propose d'ajouter un dossier au PATH pendant la configuration, accepte. + +> ⚠️ Ne mélange pas les deux méthodes, et si tu tapes `python` et que **le Microsoft Store s'ouvre** tout seul, c'est que Python n'est pas (bien) installé : Windows te propose de l'installer. Dans ce cas, reprends l'une des deux méthodes ci-dessus. + +**La commande `py` sous Windows.** Windows propose aussi un petit programme appelé `py` (le « lanceur Python »). Il fonctionne même quand le PATH est mal configuré. Si `python` ne marche pas, essaie `py` : + +```text +C:\Users\camille> py --version +Python 3.x.y +``` + +#### 2.2. Sous macOS + +macOS contient parfois un vieux Python, ou un Python installé par les outils de développement d'Apple. Ne t'en sers pas et installe la version officielle : + +1. Va sur [python.org/downloads](https://www.python.org/downloads/) et télécharge l'installateur macOS (fichier `.pkg`). +2. Lance-le et clique sur *Continuer* jusqu'à la fin. +3. À la fin, une fenêtre du Finder s'ouvre : double-clique sur **`Install Certificates.command`**. Cela permettra à Python de télécharger des choses sur Internet de façon sécurisée (utile au [chapitre 12](12-outils-venv-pip-git.md)). + +> 💡 Si tu connais déjà **Homebrew**, `brew install python` fonctionne aussi très bien. + +Sous macOS, la commande s'appelle **`python3`** (et pas `python`). + +#### 2.3. Sous Linux + +La plupart des distributions Linux (Ubuntu, Debian, Fedora…) ont **déjà Python 3** installé, car le système lui-même s'en sert. Vérifie la version (voir section suivante). Si elle est trop ancienne ou absente, utilise le gestionnaire de paquets : + +```text +# Ubuntu / Debian +sudo apt update +sudo apt install python3 python3-venv python3-pip + +# Fedora +sudo dnf install python3 +``` + +> ⚠️ Ne désinstalle **jamais** le Python fourni avec Linux : ton système en a besoin pour fonctionner. + +Sous Linux aussi, la commande s'appelle **`python3`**. + +#### 2.4. `python`, `python3` ou `py` : qui fait quoi ? + +| Système | Commande à utiliser | Remarque | +|---|---|---| +| Windows | `python` (ou `py`) | `python3` peut ouvrir le Microsoft Store : évite-la | +| macOS | `python3` | `python` n'existe souvent pas | +| Linux | `python3` | `python` peut ne pas exister, ou pointer vers autre chose | + +Dans la suite du cours, on écrira `python` dans les exemples de terminal. **Si tu es sous macOS ou Linux, remplace mentalement `python` par `python3`.** + +### 3. Le terminal : parler à l'ordinateur en texte + +Le **terminal** (on dit aussi « console » ou « ligne de commande ») est une fenêtre où tu tapes des commandes au lieu de cliquer. Ça fait un peu « film de hackers », mais tu n'as besoin que de trois ou quatre commandes. + +**Ouvrir un terminal :** + +- **Windows** : touche Windows, tape « Terminal » (ou « PowerShell »), puis Entrée. +- **macOS** : `Cmd` + `Espace`, tape « Terminal », puis Entrée. +- **Linux** : souvent `Ctrl` + `Alt` + `T`. + +Tu vois alors une ligne qui se termine par `>` (Windows) ou `$` / `%` (macOS, Linux) : c'est l'**invite de commande** (le *prompt*). Elle t'indique aussi **le dossier dans lequel tu te trouves**, appelé **dossier courant**. + +#### 3.1. Vérifier que Python est installé + +Tape la commande suivante, puis Entrée : + +```text +python --version +``` + +(ou `python3 --version` sous macOS/Linux, ou `py --version` sous Windows) + +Tu dois voir quelque chose comme `Python 3.x.y`, avec un numéro au moins égal à 3.12. 🎉 Si tu obtiens une erreur, va voir la section [Pièges fréquents](#pièges-fréquents). + +#### 3.2. Les commandes indispensables + +| Action | Windows (PowerShell) | macOS / Linux | +|---|---|---| +| Afficher le dossier courant | `pwd` (ou `cd` seul dans l'ancien `cmd`) | `pwd` | +| Lister le contenu du dossier | `dir` (ou `ls` dans PowerShell) | `ls` | +| Entrer dans un dossier | `cd nom_du_dossier` | `cd nom_du_dossier` | +| Remonter d'un niveau | `cd ..` | `cd ..` | +| Revenir au dossier personnel | `cd ~` | `cd ~` | +| Créer un dossier | `mkdir nom_du_dossier` | `mkdir nom_du_dossier` | +| Effacer l'écran | `cls` (ou `clear`) | `clear` | + +Imagine que tes dossiers sont des pièces d'une maison. `ls`/`dir` revient à regarder autour de toi, `cd cuisine` à entrer dans la cuisine, `cd ..` à revenir dans le couloir. + +Exemple de session (macOS/Linux) : + +```text +$ pwd +/home/camille +$ mkdir cours-python +$ cd cours-python +$ pwd +/home/camille/cours-python +$ ls +$ cd .. +$ pwd +/home/camille +``` + +> 💡 **Astuces qui font gagner un temps fou :** +> - La touche **Tab** complète automatiquement les noms de dossiers et de fichiers : tape `cd cou` puis Tab. +> - La **flèche du haut** rappelle les commandes précédentes. +> - Si un nom de dossier contient des espaces, mets-le entre guillemets : `cd "Mes documents"`. Mieux encore : évite les espaces et les accents dans les noms de dossiers de programmation (`cours-python` plutôt que `Cours Python élémentaire`). + +**Crée maintenant ton dossier de travail** pour tout le cours, par exemple `cours-python` dans ton dossier personnel, avec `mkdir cours-python`. + +### 4. L'interpréteur interactif : Python en direct + +Tape simplement `python` (ou `python3`) dans le terminal, sans rien d'autre : + +```text +$ python3 +Python 3.x.y (...) +Type "help", "copyright", "credits" or "license" for more information. +>>> +``` + +Les trois chevrons `>>>` signifient : « Python t'écoute ». Tu es dans l'**interpréteur interactif** (on dit aussi **REPL** : *Read, Evaluate, Print, Loop* — lire, évaluer, afficher, recommencer). Chaque ligne que tu tapes est exécutée immédiatement. + +Essaie-le comme une calculatrice : + +```pycon +>>> 2 + 3 +5 +>>> 10 - 4 * 2 +2 +>>> (10 - 4) * 2 +12 +>>> 7 / 2 +3.5 +>>> 2 ** 10 +1024 +``` + +Remarques : + +- `*` est la multiplication, `/` la division, `**` la puissance (`2 ** 10` = 2 à la puissance 10). +- Les priorités sont celles des maths : la multiplication passe avant l'addition, sauf si tu mets des parenthèses. +- Les nombres à virgule s'écrivent avec un **point** : `3.5`, pas `3,5`. + +Tu peux aussi manipuler du texte, à condition de le mettre entre guillemets : + +```pycon +>>> "Bonjour" +'Bonjour' +>>> "Bon" + "jour" +'Bonjour' +>>> "Ha" * 3 +'HaHaHa' +>>> print("Salut !") +Salut ! +``` + +Tous ces détails (types de valeurs, opérateurs) seront approfondis au [chapitre 01](01-variables-types-operateurs.md). + +**Pour quitter l'interpréteur**, tape `exit()` puis Entrée (ou `Ctrl` + `D` sous macOS/Linux, `Ctrl` + `Z` puis Entrée sous Windows). Tu reviens alors au terminal normal. + +> ⚠️ **Ne confonds pas les deux mondes !** +> - Après `$` ou `>` : tu parles au **terminal** (commandes `cd`, `ls`, `python`…). +> - Après `>>>` : tu parles à **Python** (code Python). +> +> Taper `cd Documents` après `>>>`, ou `print("Bonjour")` dans le terminal, provoque une erreur. + +L'interpréteur interactif est parfait pour tester une petite idée, mais tout ce que tu y tapes disparaît quand tu le fermes. Pour écrire de vrais programmes, on utilise des **scripts** enregistrés dans des fichiers, et un éditeur de code. + +### 5. Installer VS Code, ton éditeur de code + +Tu pourrais écrire du Python dans le Bloc-notes, mais ce serait comme écrire un roman sans correcteur d'orthographe. Un **éditeur de code** colore ton code, détecte des erreurs à la volée, complète automatiquement ce que tu tapes, et permet d'exécuter ton programme en un clic. + +Nous utiliserons **Visual Studio Code** (VS Code), gratuit et disponible sur les trois systèmes. + +1. Télécharge-le sur [code.visualstudio.com](https://code.visualstudio.com/) et installe-le. + - **Windows** : pendant l'installation, coche « Ajouter à PATH » et « Ajouter l'action *Ouvrir avec Code* », c'est pratique. + - **macOS** : glisse l'application dans le dossier *Applications*. + - **Linux** : utilise le paquet `.deb` / `.rpm` proposé, ou la logithèque de ta distribution. +2. Lance VS Code. +3. Clique sur l'icône **Extensions** dans la barre de gauche (quatre petits carrés), ou fais `Ctrl` + `Shift` + `X` (`Cmd` + `Shift` + `X` sur macOS). +4. Cherche **« Python »** et installe l'extension publiée par **Microsoft** (c'est la première, avec des millions de téléchargements). Elle installe automatiquement ses compagnons (Pylance, le débogueur…). +5. (Facultatif) Si tu préfères l'interface en français, installe aussi l'extension **« French Language Pack for Visual Studio Code »** puis redémarre VS Code. + +**Ouvrir ton dossier de travail.** Dans VS Code : *Fichier* → *Ouvrir le dossier…* (*File* → *Open Folder…*), puis choisis ton dossier `cours-python`. VS Code peut te demander si tu fais confiance aux auteurs des fichiers : réponds oui, c'est ton dossier. + +**Choisir l'interpréteur.** Ouvre la palette de commandes avec `Ctrl` + `Shift` + `P` (`Cmd` + `Shift` + `P` sur macOS), tape « Python: Select Interpreter » et choisis la version de Python que tu viens d'installer. En général VS Code la trouve tout seul. + +**Le terminal intégré.** VS Code contient son propre terminal : menu *Terminal* → *Nouveau terminal* (ou ``Ctrl`` + ``ù`` sur un clavier français, ``Ctrl`` + `` ` `` sur un clavier anglais). Il s'ouvre directement dans ton dossier de travail. C'est celui-là que tu utiliseras le plus souvent. + +### 6. Ton premier script + +Un **script** est simplement un fichier texte qui contient du code Python et dont le nom se termine par **`.py`**. + +1. Dans VS Code, crée un nouveau fichier (*Fichier* → *Nouveau fichier*, ou l'icône 📄+ à côté du nom du dossier) et nomme-le **`bonjour.py`**. +2. Tape ce code : + +```python +print("Bonjour !") +print("Je m'appelle Python.") +print("Je sais calculer : 6 x 7 =", 6 * 7) +``` + +3. **Enregistre** (`Ctrl` + `S` / `Cmd` + `S`). Un rond blanc à côté du nom de l'onglet signifie « fichier non enregistré » : Python exécute ce qui est **sur le disque**, pas ce qui est à l'écran ! +4. Exécute-le, au choix : + - clique sur le bouton ▶ (*Run Python File*) en haut à droite de l'éditeur ; + - ou, dans le terminal intégré, tape `python bonjour.py` (ou `python3 bonjour.py`). + +Sortie : + +```text +Bonjour ! +Je m'appelle Python. +Je sais calculer : 6 x 7 = 42 +``` + +Félicitations, tu es officiellement programmeur ou programmeuse ! 🎉 + +**Ce qu'il faut remarquer :** + +- `print(...)` affiche ce qu'on lui donne entre parenthèses. C'est une **fonction** : un outil prêt à l'emploi fourni par Python. (Tu apprendras à créer tes propres fonctions au [chapitre 05](05-fonctions.md).) +- Le texte est entre guillemets : `"Bonjour !"`. On appelle ça une **chaîne de caractères**. Sans guillemets, Python croirait que c'est du code. +- On peut donner plusieurs choses à `print`, séparées par des virgules : Python les affiche séparées par une espace. +- `6 * 7` n'est pas entre guillemets : Python le calcule et affiche `42`. +- Les lignes sont exécutées **dans l'ordre**, de haut en bas. + +Encore un exemple, pour voir que `print()` sans rien affiche une ligne vide : + +```python +print("Ligne 1") +print() +print("Ligne 3, après une ligne vide") +print("Le résultat de 100 / 8 est", 100 / 8) +``` + +Sortie : + +```text +Ligne 1 + +Ligne 3, après une ligne vide +Le résultat de 100 / 8 est 12.5 +``` + +> 💡 **Guillemets simples ou doubles ?** Les deux marchent : `'Bonjour'` et `"Bonjour"` sont identiques. Mais si ton texte contient une apostrophe (très fréquent en français !), utilise des guillemets doubles : `"J'adore Python"`. + +### 7. Les commentaires + +Un **commentaire** est une note pour les humains, que Python ignore complètement. Il commence par le caractère `#` et s'étend jusqu'à la fin de la ligne. + +```python +# Ce programme affiche un message de bienvenue. +# Auteur : Camille + +print("Bienvenue !") # un commentaire peut aussi suivre du code + +# print("Cette ligne ne sera pas exécutée") +print("Fin du programme") +``` + +Sortie : + +```text +Bienvenue ! +Fin du programme +``` + +**À quoi servent les commentaires ?** + +- **Expliquer le pourquoi** d'un morceau de code (« on arrondit ici parce que la banque n'accepte pas les centimes de centimes »). +- **Désactiver temporairement** une ligne sans l'effacer, pour tester quelque chose. Dans VS Code, `Ctrl` + `/` (`Cmd` + `/`) commente ou décommente les lignes sélectionnées. +- **Structurer** un long script en sections. + +> 💡 Un bon commentaire explique **pourquoi**, pas **quoi**. `print("Bonjour") # affiche Bonjour` n'apporte rien : le code le dit déjà. + +### 8. Lire ta première erreur + +Tu vas faire des erreurs. Beaucoup. Tout le monde en fait, même les experts avec 20 ans d'expérience. La différence, c'est qu'ils savent **lire les messages d'erreur**. Bonne nouvelle : les messages de Python sont parmi les plus clairs qui existent. + +Crée un fichier `erreur.py` avec ce code (il y a une faute volontaire à la ligne 2) : + +```python +print("Bonjour") +print(Bonjour) +``` + +En l'exécutant, tu obtiens : + +```text +Bonjour +Traceback (most recent call last): + File "C:\Users\camille\cours-python\erreur.py", line 2, in + print(Bonjour) + ^^^^^^^ +NameError: name 'Bonjour' is not defined +``` + +Comment lire ce message (on l'appelle un **traceback**) : + +1. **Commence par la dernière ligne** : c'est la plus importante. Elle donne le **type d'erreur** (`NameError`) et une explication (`name 'Bonjour' is not defined` : « le nom 'Bonjour' n'est pas défini »). +2. **Remonte d'une ou deux lignes** pour trouver **où** : le fichier (`erreur.py`) et le numéro de ligne (`line 2`). Python recopie même la ligne fautive et met des `^^^^` sous le morceau problématique. +3. **Remarque** que la ligne 1 a bien été exécutée (`Bonjour` s'est affiché) : Python s'arrête **à la première erreur rencontrée**. + +Ici, on a oublié les guillemets : Python croit que `Bonjour` est un nom de quelque chose qu'il devrait connaître. La correction : `print("Bonjour")`. + +Il existe aussi des erreurs détectées **avant même l'exécution**, quand Python relit ton code : les **erreurs de syntaxe** (`SyntaxError`). C'est l'équivalent d'une faute de grammaire. + +```python +print("Bonjour" +``` + +```text + File "C:\Users\camille\cours-python\erreur.py", line 1 + print("Bonjour" + ^ +SyntaxError: '(' was never closed +``` + +Traduction : « la parenthèse ouverte n'a jamais été fermée ». Dans ce cas, **rien** n'est exécuté, même pas les lignes correctes situées avant. + +**Petit dictionnaire des erreurs que tu croiseras au début :** + +| Message | Signification | Cause probable | +|---|---|---| +| `SyntaxError: invalid syntax` | Syntaxe invalide | Faute de frappe, symbole manquant ou en trop | +| `SyntaxError: '(' was never closed` | Parenthèse jamais fermée | Il manque `)` | +| `SyntaxError: unterminated string literal` | Chaîne non terminée | Il manque un guillemet fermant | +| `IndentationError: unexpected indent` | Décalage inattendu | Espaces en trop en début de ligne | +| `NameError: name '...' is not defined` | Nom inconnu | Faute de frappe, ou guillemets oubliés | +| `ZeroDivisionError: division by zero` | Division par zéro | On a divisé par 0 | + +> 💡 **Le réflexe à prendre :** dernière ligne → numéro de ligne → regarder la ligne en question **et celle d'avant** (une parenthèse oubliée à la ligne 3 est parfois signalée à la ligne 4). Et si tu ne comprends pas, copie la dernière ligne du message dans un moteur de recherche : quelqu'un a déjà eu le même problème. Les erreurs et le débogage seront approfondis au [chapitre 08](08-erreurs-et-debogage.md). + +--- + +## Pièges fréquents + +### Piège 1 — « python n'est pas reconnu » (Windows) + +```text +PS C:\Users\camille> python --version +python : Le terme «python» n'est pas reconnu comme nom d'applet de commande, fonction, fichier de script ou programme exécutable. +``` + +**Cause :** la case « Add python.exe to PATH » n'a pas été cochée pendant l'installation. + +**Correction :** essaie d'abord `py --version`. Si ça marche, tu peux utiliser `py` à la place de `python`. Sinon, relance l'installateur de Python, choisis *Modify* (ou désinstalle puis réinstalle) et coche bien la case. **Ferme et rouvre ensuite le terminal (et VS Code)** : un terminal déjà ouvert ne voit pas les changements du PATH. + +### Piège 2 — « command not found: python » (macOS / Linux) + +```text +$ python --version +zsh: command not found: python +``` + +**Cause :** sur macOS et Linux, la commande s'appelle `python3`. + +**Correction :** + +```text +$ python3 --version +Python 3.x.y +``` + +### Piège 3 — Taper du Python dans le terminal, ou une commande de terminal dans Python + +Dans le terminal (pas dans Python) : + +```text +$ print("Bonjour") +bash: syntax error near unexpected token `"Bonjour"' +``` + +Dans l'interpréteur Python : + +```text +>>> python bonjour.py + File "", line 1 + python bonjour.py + ^^^^^^^ +SyntaxError: invalid syntax +``` + +**Correction :** regarde l'invite. `>>>` = Python ; `$`, `%` ou `>` = terminal. Pour lancer un script, quitte d'abord l'interpréteur avec `exit()`, puis tape `python bonjour.py` dans le terminal. + +### Piège 4 — « No such file or directory » + +```text +$ python3 bonjour.py +python3: can't open file '/home/camille/bonjour.py': [Errno 2] No such file or directory +``` + +**Cause :** tu n'es pas dans le dossier qui contient le fichier (ici, on est dans `/home/camille` alors que le fichier est dans `/home/camille/cours-python`), ou le fichier n'a pas le nom que tu crois (par exemple `bonjour.py.txt` sous Windows). + +**Correction :** vérifie où tu es (`pwd`) et ce qu'il y a dans le dossier (`ls` ou `dir`), puis déplace-toi avec `cd cours-python`. Utiliser le terminal intégré de VS Code, qui s'ouvre directement dans le bon dossier, évite souvent ce problème. + +### Piège 5 — Oublier d'enregistrer + +Tu modifies ton code, tu l'exécutes… et rien ne change. Le rond blanc sur l'onglet de VS Code te dit que le fichier n'est **pas enregistré**. + +**Correction :** `Ctrl` + `S` (`Cmd` + `S`) avant chaque exécution. Mieux : active *Fichier* → *Enregistrement automatique* (*Auto Save*). + +### Piège 6 — Les guillemets « typographiques » + +Si tu copies du code depuis un traitement de texte (Word, un PDF…), les guillemets droits `"` sont parfois remplacés par des guillemets courbes `“ ”` : + +```python +print(“Bonjour”) +``` + +```text + File "bonjour.py", line 1 + print(“Bonjour”) + ^ +SyntaxError: invalid character '“' (U+201C) +``` + +**Correction :** retape les guillemets directement dans VS Code : `print("Bonjour")`. Les guillemets français `« »` provoquent la même erreur. + +### Piège 7 — Les majuscules comptent + +```python +Print("Bonjour") +``` + +```text +Traceback (most recent call last): + File "bonjour.py", line 1, in + Print("Bonjour") + ^^^^^ +NameError: name 'Print' is not defined. Did you mean: 'print'? +``` + +**Correction :** Python est **sensible à la casse** : `print`, `Print` et `PRINT` sont trois noms différents. Remarque que Python te suggère même la bonne réponse : *Did you mean: 'print'?* + +### Piège 8 — Des espaces en trop en début de ligne + +```python +print("Bonjour") + print("Au revoir") +``` + +```text + File "bonjour.py", line 2 + print("Au revoir") +IndentationError: unexpected indent +``` + +**Correction :** en Python, les espaces en **début** de ligne (l'**indentation**) ont un sens précis, que tu découvriras au [chapitre 02](02-conditions.md). Pour l'instant, toutes tes lignes doivent commencer tout à gauche. + +--- + +## Exercices + +### Exercice 1 ⭐ — Vérification de l'installation + +Ouvre un terminal et : + +1. affiche la version de Python installée ; +2. crée un dossier `chapitre00` dans ton dossier de travail et entre dedans ; +3. affiche le chemin du dossier courant pour vérifier que tu es au bon endroit ; +4. lance l'interpréteur interactif, calcule `2 ** 100`, puis quitte-le. + +Résultat attendu pour le calcul : `1267650600228229401496703205376`. + +### Exercice 2 ⭐ — Calculatrice interactive + +Dans l'interpréteur interactif, calcule : + +1. le nombre de secondes dans une journée ; +2. le nombre de minutes dans une année de 365 jours ; +3. le prix de 3 croissants à 1,20 € et 2 baguettes à 1,10 € ; +4. la moyenne de 12, 15 et 17. + +Attention aux priorités de calcul et à la virgule ! + +### Exercice 3 ⭐ — Présente-toi + +Écris un script `presentation.py` qui affiche exactement (en mettant tes propres informations) : + +```text +Bonjour ! +Je m'appelle Camille. +J'apprends Python. + +À bientôt ! +``` + +Note la ligne vide avant la dernière ligne. + +### Exercice 4 ⭐⭐ — Chasse aux erreurs + +Le script suivant contient **quatre** erreurs. Exécute-le, lis chaque message d'erreur, corrige la ligne fautive et recommence jusqu'à ce que tout fonctionne. + +```text +print("Début du programme") +print("Le double de 21 est", 21 * 2 +Print("Python est sensible à la casse") +print("Il manque un guillemet) +print(Fin du programme) +``` + +Sortie attendue une fois corrigé : + +```text +Début du programme +Le double de 21 est 42 +Python est sensible à la casse +Il manque un guillemet +Fin du programme +``` + +### Exercice 5 ⭐⭐ — Le commentateur + +Écris un script `recette.py` qui affiche une petite recette de crêpes (titre, ingrédients, étapes) sur une dizaine de lignes. Ajoute : + +- un commentaire en tête avec ton nom et la date ; +- un commentaire avant chaque section (titre, ingrédients, étapes) ; +- une ligne d'étape « désactivée » par un commentaire, qui ne doit pas s'afficher. + +### Exercice 6 ⭐⭐ — Facture rapide + +Écris un script `facture.py` qui affiche une facture et laisse Python faire **tous** les calculs (n'écris pas les résultats toi-même). Un client achète 4 cahiers à 2.5 € et 3 stylos à 1.2 €. + +Sortie attendue : + +```text +=== FACTURE === +Cahiers : 4 x 2.5 = 10.0 € +Stylos : 3 x 1.2 = 3.5999999999999996 € +TOTAL : 13.6 € +``` + +Ne t'inquiète pas du `3.5999999999999996` : c'est normal, et tu sauras pourquoi (et comment l'arrondir) au chapitre 01. + +### Exercice 7 ⭐⭐⭐ — Art ASCII + +Écris un script `chat.py` qui dessine ce chat (attention : la barre oblique inversée `\` est un caractère spécial en Python ; pour l'afficher, il faut l'écrire en double `\\` dans une chaîne). + +```text + /\_/\ +( o.o ) + > ^ < +``` + +Puis ajoute un cadre de 20 étoiles au-dessus et au-dessous, **sans taper les 20 étoiles à la main** (indice : tu as vu ce que fait `"Ha" * 3`). + +--- + +## Mini-projet + +### Ma carte de visite dans le terminal + +**Objectif :** créer un script `carte_visite.py` qui affiche une jolie carte de visite à ton nom, encadrée, et qui calcule une ou deux informations « amusantes ». + +**Cahier des charges :** + +1. Le fichier commence par un commentaire d'en-tête : nom du programme, auteur, date, description en une phrase. +2. La carte est encadrée par une ligne de 40 caractères `=` en haut et en bas, générée avec l'opérateur `*` sur une chaîne. +3. Elle contient au moins : ton prénom, ta ville, un loisir, et la phrase « Apprenti·e Pythoniste ». +4. Une section « Le saviez-vous ? » affiche au moins deux calculs faits par Python, par exemple ton âge approximatif en jours (âge × 365) et le nombre d'heures de sommeil par an (8 × 365). +5. Les sections sont séparées par des lignes vides et commentées dans le code. +6. Le script s'exécute sans aucune erreur depuis le terminal **et** depuis le bouton ▶ de VS Code. + +**Étapes suggérées :** + +1. Crée le fichier dans ton dossier de travail et écris le commentaire d'en-tête. +2. Affiche le cadre du haut, puis teste. +3. Ajoute les lignes d'information une par une, en testant à chaque fois. +4. Ajoute la section des calculs. +5. Termine par le cadre du bas et relis les commentaires. + +**Pistes d'amélioration :** + +- Ajoute un petit dessin ASCII (un chat, une tasse de café…). +- Centre visuellement les textes en ajoutant des espaces. +- Ajoute le nombre de battements de cœur approximatif depuis ta naissance (environ 70 par minute). +- Quand tu auras lu le chapitre 01, reviens améliorer ce script avec des variables et une saisie au clavier ! + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +Sous macOS / Linux (sous Windows, remplace `python3` par `python` ou `py`, et `pwd` fonctionne dans PowerShell) : + +```text +$ python3 --version +Python 3.x.y +$ mkdir chapitre00 +$ cd chapitre00 +$ pwd +/home/camille/cours-python/chapitre00 +$ python3 +>>> 2 ** 100 +1267650600228229401496703205376 +>>> exit() +$ +``` + +Remarque : Python calcule sans problème des nombres entiers gigantesques, contrairement à une calculatrice de poche qui afficherait `1.2676506e+30`. + +
+ +
+Corrigé de l'exercice 2 + +```pycon +>>> 24 * 60 * 60 +86400 +>>> 365 * 24 * 60 +525600 +>>> 3 * 1.20 + 2 * 1.10 +5.8 +>>> (12 + 15 + 17) / 3 +14.666666666666666 +``` + +- Pour les prix, on écrit `1.20` avec un point, jamais une virgule. (Parfois, un calcul avec des nombres à virgule donne un résultat comme `3.5999999999999996` au lieu de `3.6` : c'est une petite imprécision normale en informatique, expliquée au chapitre 01.) +- Pour la moyenne, les **parenthèses sont indispensables** : `12 + 15 + 17 / 3` calculerait d'abord `17 / 3` et donnerait `32.666666666666664`. + +
+ +
+Corrigé de l'exercice 3 + +```python +print("Bonjour !") +print("Je m'appelle Camille.") +print("J'apprends Python.") +print() +print("À bientôt !") +``` + +Sortie : + +```text +Bonjour ! +Je m'appelle Camille. +J'apprends Python. + +À bientôt ! +``` + +On utilise des guillemets doubles car le texte contient des apostrophes. `print()` sans argument affiche une ligne vide. + +
+ +
+Corrigé de l'exercice 4 + +Les quatre erreurs, dans l'ordre où Python les signale : + +1. Ligne 4 : `SyntaxError: unterminated string literal (detected at line 4)` → il manque le guillemet fermant. Surprise : ce n'est pas la ligne 2 qui est signalée en premier ! Python découpe d'abord tout le fichier en « mots », et une chaîne non terminée bloque ce découpage dès le départ. +2. Ligne 2 : `SyntaxError: '(' was never closed` → il manque la parenthèse fermante. +3. Ligne 5 : `SyntaxError: invalid syntax. Perhaps you forgot a comma?` → Python lit `Fin du programme` comme trois noms de code à la suite, ce qui n'a pas de sens. La vraie cause : les guillemets oubliés. +4. Ligne 3 : `NameError: name 'Print' is not defined. Did you mean: 'print'?` → majuscule en trop. Cette erreur n'apparaît qu'en dernier, **pendant l'exécution** : les deux premières lignes s'affichent avant que Python ne plante. + +À retenir : tant qu'il reste une erreur de syntaxe (`SyntaxError`), **rien** ne s'exécute. Les autres erreurs (`NameError`…) n'apparaissent qu'au moment où Python atteint la ligne fautive. + +Version corrigée : + +```python +print("Début du programme") +print("Le double de 21 est", 21 * 2) +print("Python est sensible à la casse") +print("Il manque un guillemet") +print("Fin du programme") +``` + +Sortie : + +```text +Début du programme +Le double de 21 est 42 +Python est sensible à la casse +Il manque un guillemet +Fin du programme +``` + +
+ +
+Corrigé de l'exercice 5 + +```python +# Recette de crêpes +# Auteur : Camille — 14 octobre + +# --- Titre --- +print("=== Crêpes pour 4 personnes ===") +print() + +# --- Ingrédients --- +print("Ingrédients :") +print("- 250 g de farine") +print("- 4 œufs") +print("- 50 cl de lait") +print("- 1 pincée de sel") +print() + +# --- Étapes --- +print("Étapes :") +print("1. Mélanger la farine et les œufs.") +print("2. Ajouter le lait petit à petit.") +# print("2bis. Ajouter un verre de rhum.") # étape désactivée +print("3. Laisser reposer 1 heure.") +print("4. Cuire dans une poêle chaude.") +``` + +Sortie : + +```text +=== Crêpes pour 4 personnes === + +Ingrédients : +- 250 g de farine +- 4 œufs +- 50 cl de lait +- 1 pincée de sel + +Étapes : +1. Mélanger la farine et les œufs. +2. Ajouter le lait petit à petit. +3. Laisser reposer 1 heure. +4. Cuire dans une poêle chaude. +``` + +La ligne « 2bis » est commentée : Python l'ignore. + +
+ +
+Corrigé de l'exercice 6 + +```python +# Facture : Python fait tous les calculs +print("=== FACTURE ===") +print("Cahiers : 4 x 2.5 =", 4 * 2.5, "€") +print("Stylos : 3 x 1.2 =", 3 * 1.2, "€") +print("TOTAL :", 4 * 2.5 + 3 * 1.2, "€") +``` + +Sortie : + +```text +=== FACTURE === +Cahiers : 4 x 2.5 = 10.0 € +Stylos : 3 x 1.2 = 3.5999999999999996 € +TOTAL : 13.6 € +``` + +Dans `"4 x 2.5 ="`, le calcul est entre guillemets : c'est du texte, affiché tel quel. Juste après, `4 * 2.5` est hors guillemets : Python le calcule. Remarque qu'on recopie les mêmes nombres plusieurs fois : au chapitre 01, les **variables** permettront d'éviter ça. + +
+ +
+Corrigé de l'exercice 7 + +```python +# Un chat en art ASCII, encadré d'étoiles +print("*" * 20) +print(" /\\_/\\") +print("( o.o )") +print(" > ^ <") +print("*" * 20) +``` + +Sortie : + +```text +******************** + /\_/\ +( o.o ) + > ^ < +******************** +``` + +Chaque `\\` dans le code produit **un seul** `\` à l'écran. `"*" * 20` répète la chaîne `"*"` vingt fois. + +
+ +
+Corrigé du mini-projet + +```python +# ------------------------------------------------------------ +# carte_visite.py +# Auteur : Camille +# Date : 14 octobre +# Description : affiche ma carte de visite dans le terminal. +# ------------------------------------------------------------ + +# --- Cadre du haut --- +print("=" * 40) + +# --- Identité --- +print(" Camille") +print(" Apprentie Pythoniste") +print() + +# --- Informations --- +print(" Ville : Lyon") +print(" Loisir : l'escalade") +print() + +# --- Le saviez-vous ? (calculs faits par Python) --- +print(" Le saviez-vous ?") +print(" J'ai vécu environ", 28 * 365, "jours.") +print(" Je dors environ", 8 * 365, "heures par an.") +print(" Mon cœur a battu environ", 70 * 60 * 24 * 365 * 28, "fois.") + +# --- Cadre du bas --- +print("=" * 40) +``` + +Sortie : + +```text +======================================== + Camille + Apprentie Pythoniste + + Ville : Lyon + Loisir : l'escalade + + Le saviez-vous ? + J'ai vécu environ 10220 jours. + Je dors environ 2920 heures par an. + Mon cœur a battu environ 1030176000 fois. +======================================== +``` + +Points à vérifier : le commentaire d'en-tête est présent, chaque section est commentée, les cadres sont générés avec `"=" * 40`, et tous les nombres sont calculés par Python. Le nombre de battements de cœur correspond à l'une des pistes d'amélioration. + +
+ +--- + +## Récapitulatif + +**Points clés :** + +- Python est un langage **interprété** : l'interpréteur lit ton code de haut en bas et l'exécute ligne par ligne. +- Installe **la dernière version stable de Python 3** depuis python.org. Sous Windows, **coche « Add python.exe to PATH »** (ou utilise le Python install manager). +- La commande est `python` (ou `py`) sous Windows, `python3` sous macOS et Linux. +- Dans le terminal : `cd` pour te déplacer, `cd ..` pour remonter, `ls` / `dir` pour lister, `pwd` pour savoir où tu es, `mkdir` pour créer un dossier. +- `>>>` = interpréteur interactif (tests rapides) ; un fichier `.py` = script (programme durable). On lance un script avec `python nom_du_fichier.py`. +- VS Code + l'extension Python de Microsoft : ton atelier de travail. N'oublie pas d'enregistrer avant d'exécuter. +- `print(...)` affiche des valeurs ; le texte se met entre guillemets. +- `#` commence un commentaire, ignoré par Python. +- Un message d'erreur se lit **de bas en haut** : type d'erreur, explication, puis numéro de ligne. + +**Checklist de validation :** + +- [ ] `python --version` (ou `python3 --version`) affiche une version 3.12 ou plus récente. +- [ ] Je sais ouvrir un terminal, savoir où je suis et me déplacer dans mes dossiers. +- [ ] Je sais lancer l'interpréteur interactif, y faire un calcul et le quitter. +- [ ] VS Code est installé avec l'extension Python, et mon dossier de travail est ouvert dedans. +- [ ] J'ai écrit, enregistré et exécuté un script `.py` de deux façons (bouton ▶ et terminal). +- [ ] Je sais écrire un commentaire et désactiver une ligne. +- [ ] Face à une erreur, je sais trouver son type et le numéro de la ligne fautive. + +--- + +[← Chapitre précédent (sommaire)](README.md) | [Sommaire](README.md) | [Chapitre suivant →](01-variables-types-operateurs.md) diff --git a/docs/presentations/Python/01-variables-types-operateurs.md b/docs/presentations/Python/01-variables-types-operateurs.md new file mode 100644 index 0000000..fc7a2c0 --- /dev/null +++ b/docs/presentations/Python/01-variables-types-operateurs.md @@ -0,0 +1,1616 @@ +# Chapitre 01 — Variables, types et opérateurs + +> **⏱️ Durée estimée :** 4 à 5 heures +> +> **📋 Prérequis :** [Chapitre 00 — Installer Python et son environnement de travail](00-installation-environnement.md) : savoir créer et exécuter un script `.py`, et utiliser l'interpréteur interactif. +> +> **🎯 Objectifs — À la fin de ce chapitre, tu sauras :** +> - créer des variables, leur donner de bons noms et modifier leur valeur ; +> - reconnaître les quatre types de base : `int`, `float`, `str` et `bool` ; +> - faire des calculs avec tous les opérateurs arithmétiques, y compris `//`, `%` et `**` ; +> - dialoguer avec l'utilisateur grâce à `input()` et `print()` ; +> - convertir une valeur d'un type à un autre (`int()`, `float()`, `str()`) ; +> - produire des affichages propres et bien formatés avec les f-strings. + +--- + +## Cours + +### 1. Les variables : donner un nom à une valeur + +Au chapitre précédent, dans l'exercice de la facture, on recopiait les mêmes nombres plusieurs fois. Si le prix changeait, il fallait le modifier partout. Les **variables** règlent ce problème. + +Une variable, c'est un **nom** qui désigne une **valeur**. Imagine une étiquette que tu colles sur un objet : l'étiquette « prix_cahier » est collée sur le nombre `2.5`. Quand tu écris `prix_cahier` dans ton code, Python va chercher la valeur qui porte cette étiquette. + +```python +prix_cahier = 2.5 +quantite = 4 +total = prix_cahier * quantite +print(total) +``` + +Sortie : + +```text +10.0 +``` + +Le signe `=` s'appelle **l'opérateur d'affectation**. Attention, il ne signifie **pas** « est égal à » comme en maths ! Il se lit **de droite à gauche** : « calcule ce qui est à droite, puis colle l'étiquette de gauche sur le résultat ». + +```python +age = 30 # la variable age désigne 30 +age = age + 1 # on calcule age + 1 (= 31), puis age désigne 31 +print(age) +``` + +Sortie : + +```text +31 +``` + +En maths, `age = age + 1` serait absurde. En Python, c'est parfaitement normal : on **met à jour** la variable. + +#### 1.1. Réaffecter une variable + +Une variable peut changer de valeur autant de fois que tu veux. Seule la dernière affectation compte : + +```python +meteo = "soleil" +print(meteo) +meteo = "pluie" +print(meteo) +``` + +Sortie : + +```text +soleil +pluie +``` + +#### 1.2. Une variable doit exister avant d'être utilisée + +Python lit ton code de haut en bas. Si tu utilises un nom avant de l'avoir créé, il ne le connaît pas encore : + +```python +print(prenom) +prenom = "Camille" +``` + +```text +Traceback (most recent call last): + File "variables.py", line 1, in + print(prenom) + ^^^^^^ +NameError: name 'prenom' is not defined +``` + +#### 1.3. Bien nommer ses variables + +Les règles **obligatoires** (sinon, erreur) : + +- un nom contient uniquement des lettres, des chiffres et le caractère `_` (tiret bas, ou *underscore*) ; +- il ne commence **pas** par un chiffre (`2nom` est interdit, `nom2` est autorisé) ; +- il ne contient **pas d'espace** (`mon nom` est interdit) ; +- il n'est pas un **mot réservé** de Python, comme `if`, `for`, `while`, `True`, `class`, `def`, `import`… +- les majuscules comptent : `age`, `Age` et `AGE` sont trois variables différentes. + +Les **conventions** (pas obligatoires, mais suivies par tous les programmeurs Python, voir la PEP 8 au [chapitre 13](13-tests-pytest-bonnes-pratiques.md)) : + +- tout en minuscules, mots séparés par des `_` : `prix_total`, `nombre_eleves`. C'est le style ***snake_case*** (« style serpent ») ; +- des noms **parlants** : `prix_total` plutôt que `pt` ou `x` ; +- dans ce cours, des noms en français **sans accents** : `annee` plutôt que `année`. (Python accepte les accents dans les noms, mais ils compliquent la vie quand on partage son code.) + +```python +# ✅ Bons noms +prix_total = 13.6 +nombre_eleves = 28 +age_minimum = 18 + +# ❌ Mauvais noms (autorisés, mais à éviter) +x = 13.6 # que représente x ? +NombreEleves = 28 # pas le style Python pour une variable +a_m = 18 # abréviation obscure +``` + +#### 1.4. Affectations multiples + +Python permet d'affecter plusieurs variables d'un coup : + +```python +largeur, hauteur = 1920, 1080 +print(largeur, hauteur) + +a = b = 0 # a et b valent toutes les deux 0 +print(a, b) +``` + +Sortie : + +```text +1920 1080 +0 0 +``` + +Et voici une astuce célèbre : **échanger** deux variables en une ligne. + +```python +gauche = "chat" +droite = "chien" +gauche, droite = droite, gauche +print(gauche, droite) +``` + +Sortie : + +```text +chien chat +``` + +### 2. Les types de base + +Chaque valeur en Python a un **type**, c'est-à-dire une nature : un nombre entier, un texte, etc. Le type détermine ce qu'on peut faire avec la valeur. On ne fait pas les mêmes opérations avec un prix qu'avec un prénom. + +Les quatre types de base à connaître : + +| Type | Nom complet | Exemples | Sert à représenter | +|---|---|---|---| +| `int` | *integer* (entier) | `42`, `-7`, `0`, `1_000_000` | des quantités entières, des compteurs | +| `float` | *floating point* (nombre à virgule) | `3.14`, `-0.5`, `2.0`, `1e3` | des mesures, des prix, des moyennes | +| `str` | *string* (chaîne de caractères) | `"Bonjour"`, `'A'`, `""`, `"42"` | du texte | +| `bool` | *boolean* (booléen) | `True`, `False` | une réponse oui/non | + +La fonction `type()` te dit de quel type est une valeur : + +```python +print(type(42)) +print(type(3.14)) +print(type("Bonjour")) +print(type(True)) +print(type("42")) +``` + +Sortie : + +```text + + + + + +``` + +Remarque la dernière ligne : `"42"` entre guillemets est **du texte**, pas un nombre ! C'est une source d'erreurs très fréquente, on y reviendra avec `input()`. + +#### 2.1. Les entiers (`int`) + +Les entiers n'ont **pas de limite de taille** en Python. Pour les grands nombres, tu peux utiliser `_` comme séparateur de milliers, Python l'ignore : + +```python +population_france = 68_000_000 +grains_de_riz = 2 ** 64 +print(population_france) +print(grains_de_riz) +``` + +Sortie : + +```text +68000000 +18446744073709551616 +``` + +#### 2.2. Les nombres à virgule (`float`) + +On les écrit avec un **point** décimal. Un nombre comme `2.0` est un `float`, même s'il « tombe juste ». L'écriture `1e3` signifie 1 × 10³, soit `1000.0`. + +Les floats ont une petite particularité : l'ordinateur les stocke en binaire, ce qui provoque parfois de minuscules imprécisions. + +```python +print(0.1 + 0.2) +print(3 * 1.2) +``` + +Sortie : + +```text +0.30000000000000004 +3.5999999999999996 +``` + +Ce n'est **pas un bug** de Python : c'est pareil dans presque tous les langages. C'est comme essayer d'écrire 1/3 en décimal : 0,3333… on doit bien s'arrêter quelque part. La solution pour l'affichage : arrondir, avec `round()` ou avec les f-strings (section 8). + +```python +print(round(0.1 + 0.2, 2)) # arrondi à 2 chiffres après la virgule +print(round(3.14159, 3)) +print(round(7.6)) # sans 2e argument : arrondi à l'entier +``` + +Sortie : + +```text +0.3 +3.142 +8 +``` + +#### 2.3. Les chaînes de caractères (`str`) + +Une chaîne est une suite de caractères entre guillemets simples `'...'` ou doubles `"..."`. On verra leurs nombreux outils au [chapitre 06](06-chaines-et-comprehensions.md) ; pour l'instant, voici l'essentiel. + +```python +prenom = "Camille" +message = 'Il a dit "bonjour"' # guillemets doubles à l'intérieur de simples +phrase = "J'apprends Python" # apostrophe à l'intérieur de doubles +vide = "" # une chaîne vide, c'est permis +print(prenom) +print(message) +print(phrase) +print(len(phrase)) # len() donne le nombre de caractères +``` + +Sortie : + +```text +Camille +Il a dit "bonjour" +J'apprends Python +17 +``` + +#### 2.4. Les booléens (`bool`) + +Un booléen ne peut valoir que `True` (vrai) ou `False` (faux), avec une majuscule. Ils servent à représenter une réponse à une question oui/non : + +```python +est_majeur = True +a_un_billet = False +print(est_majeur, a_un_billet) +print(10 > 3) # une comparaison produit un booléen +``` + +Sortie : + +```text +True False +True +``` + +Les booléens et les comparaisons (`>`, `==`, etc.) sont au cœur du [chapitre 02](02-conditions.md), où ils permettront à ton programme de prendre des décisions. + +#### 2.5. Le typage dynamique + +En Python, ce n'est pas la variable qui a un type, c'est la **valeur**. Une même variable peut désigner un entier, puis un texte : + +```python +truc = 42 +print(type(truc)) +truc = "quarante-deux" +print(type(truc)) +``` + +Sortie : + +```text + + +``` + +C'est permis, mais **déconseillé** : une variable devrait toujours représenter la même sorte de chose. + +### 3. Les opérateurs arithmétiques + +| Opérateur | Opération | Exemple | Résultat | +|---|---|---|---| +| `+` | addition | `7 + 2` | `9` | +| `-` | soustraction | `7 - 2` | `5` | +| `*` | multiplication | `7 * 2` | `14` | +| `/` | division | `7 / 2` | `3.5` | +| `//` | division entière | `7 // 2` | `3` | +| `%` | modulo (reste de la division entière) | `7 % 2` | `1` | +| `**` | puissance | `7 ** 2` | `49` | + +#### 3.1. La division `/` donne toujours un float + +```python +print(10 / 2) +print(type(10 / 2)) +``` + +Sortie : + +```text +5.0 + +``` + +Même quand la division tombe juste, le résultat est un `float` (`5.0`, pas `5`). + +#### 3.2. Division entière `//` et modulo `%` + +Souviens-toi de la division posée de l'école primaire : 17 divisé par 5, ça fait **3**, et il **reste 2**. En Python : + +- `17 // 5` donne le **quotient** : `3` ; +- `17 % 5` donne le **reste** : `2`. + +Analogie : tu as 17 bonbons à répartir dans des sachets de 5. Tu remplis `17 // 5 = 3` sachets complets, et il te reste `17 % 5 = 2` bonbons. + +```python +bonbons = 17 +par_sachet = 5 +sachets = bonbons // par_sachet +reste = bonbons % par_sachet +print("Sachets complets :", sachets) +print("Bonbons restants :", reste) +``` + +Sortie : + +```text +Sachets complets : 3 +Bonbons restants : 2 +``` + +Ces deux opérateurs sont incroyablement utiles. Par exemple pour convertir une durée : + +```python +duree_totale = 200 # en minutes +heures = duree_totale // 60 +minutes = duree_totale % 60 +print(duree_totale, "minutes =", heures, "h", minutes, "min") +``` + +Sortie : + +```text +200 minutes = 3 h 20 min +``` + +Ou pour récupérer le dernier chiffre d'un nombre : `nombre % 10` (le reste de la division par 10). + +```python +nombre = 2847 +print(nombre % 10) # dernier chiffre +print(nombre // 10) # le nombre sans son dernier chiffre +``` + +Sortie : + +```text +7 +284 +``` + +> 💡 Un nombre est **pair** si `nombre % 2` vaut `0`, et **impair** s'il vaut `1`. Tu t'en serviras beaucoup au chapitre 02. + +#### 3.3. Puissance `**` + +```python +print(2 ** 3) # 2 × 2 × 2 +print(10 ** 6) # un million +print(9 ** 0.5) # puissance 0.5 = racine carrée +``` + +Sortie : + +```text +8 +1000000 +3.0 +``` + +#### 3.4. Priorité des opérateurs + +Python respecte les priorités mathématiques habituelles. Du plus prioritaire au moins prioritaire : + +1. les parenthèses `( )` ; +2. la puissance `**` ; +3. le signe moins devant un nombre (`-x`) ; +4. `*`, `/`, `//`, `%` (de gauche à droite) ; +5. `+`, `-` (de gauche à droite). + +```python +print(2 + 3 * 4) # la multiplication d'abord +print((2 + 3) * 4) # les parenthèses d'abord +print(-2 ** 2) # la puissance passe avant le signe moins ! +print((-2) ** 2) +print(10 - 4 - 3) # de gauche à droite : (10 - 4) - 3 +``` + +Sortie : + +```text +14 +20 +-4 +4 +3 +``` + +> 💡 Dans le doute, **mets des parenthèses**. Elles ne coûtent rien et rendent le calcul plus lisible. + +#### 3.5. Mélanger `int` et `float` + +Quand un calcul mélange un `int` et un `float`, le résultat est un `float` : + +```python +print(3 + 2.0) +print(4 * 2.5) +``` + +Sortie : + +```text +5.0 +10.0 +``` + +#### 3.6. Quelques fonctions mathématiques intégrées + +Python fournit d'office quelques fonctions bien pratiques : + +```python +print(abs(-7)) # valeur absolue +print(round(2.567, 1)) # arrondi +print(max(4, 9, 2)) # le plus grand +print(min(4, 9, 2)) # le plus petit +``` + +Sortie : + +```text +7 +2.6 +9 +2 +``` + +Pour des fonctions plus avancées (racine carrée, sinus, π…), il existe le module `math`, que tu découvriras au [chapitre 09](09-modules-bibliotheque-standard.md). + +### 4. Les opérateurs d'affectation composée + +Mettre à jour une variable à partir de sa propre valeur est si fréquent qu'il existe des raccourcis : + +| Écriture longue | Raccourci | +|---|---| +| `x = x + 3` | `x += 3` | +| `x = x - 3` | `x -= 3` | +| `x = x * 3` | `x *= 3` | +| `x = x / 3` | `x /= 3` | +| `x = x // 3` | `x //= 3` | +| `x = x % 3` | `x %= 3` | +| `x = x ** 3` | `x **= 3` | + +```python +score = 0 +score += 10 # on gagne 10 points +score += 5 # puis 5 +score -= 3 # on en perd 3 +score *= 2 # bonus : score doublé +print("Score final :", score) +``` + +Sortie : + +```text +Score final : 24 +``` + +> ⚠️ Python n'a **pas** d'opérateur `++` comme d'autres langages. Pour ajouter 1, écris `compteur += 1`. + +### 5. Opérations sur les chaînes + +#### 5.1. Concaténation `+` et répétition `*` + +```python +prenom = "Ada" +nom = "Lovelace" +nom_complet = prenom + " " + nom # on « colle » les chaînes +print(nom_complet) +print("-" * 20) # on répète la chaîne 20 fois +print("Bip" * 3) +``` + +Sortie : + +```text +Ada Lovelace +-------------------- +BipBipBip +``` + +> ⚠️ `+` ne colle que des chaînes **entre elles**. `"J'ai " + 25 + " ans"` provoque une erreur (voir les [pièges fréquents](#pièges-fréquents)). Les f-strings de la section 8 sont bien plus pratiques. + +#### 5.2. Caractères spéciaux + +Certains caractères s'écrivent avec une **barre oblique inversée** `\` (antislash) : + +| Séquence | Signification | +|---|---| +| `\n` | retour à la ligne | +| `\t` | tabulation | +| `\\` | un antislash | +| `\"` | un guillemet double (dans une chaîne entre `"`) | +| `\'` | un guillemet simple (dans une chaîne entre `'`) | + +```python +print("Ligne 1\nLigne 2") +print("Nom :\tCamille") +print("C:\\Users\\camille") +print('L\'école') +``` + +Sortie : + +```text +Ligne 1 +Ligne 2 +Nom : Camille +C:\Users\camille +L'école +``` + +#### 5.3. Chaînes sur plusieurs lignes + +Avec trois guillemets (`"""` ou `'''`), une chaîne peut s'étendre sur plusieurs lignes : + +```python +menu = """=== MENU === +1. Jouer +2. Options +3. Quitter""" +print(menu) +``` + +Sortie : + +```text +=== MENU === +1. Jouer +2. Options +3. Quitter +``` + +### 6. Afficher avec `print()` + +Tu connais déjà `print()`. Voici deux options utiles : `sep` et `end`. + +- `sep` (*separator*) : ce qui est placé **entre** les valeurs. Par défaut, une espace. +- `end` : ce qui est affiché **à la fin**. Par défaut, un retour à la ligne `\n`. + +```python +print("01", "02", "2025", sep="/") +print("a", "b", "c", sep="") +print("Chargement", end="...") +print("terminé !") +``` + +Sortie : + +```text +01/02/2025 +abc +Chargement...terminé ! +``` + +Sans `end="..."`, « terminé ! » aurait été affiché sur une nouvelle ligne. + +### 7. Demander des informations avec `input()` + +Jusqu'ici, nos programmes faisaient toujours la même chose. Avec `input()`, ils deviennent **interactifs** : le programme pose une question, **attend** que l'utilisateur tape une réponse et appuie sur Entrée, puis récupère ce qui a été tapé. + +```python +prenom = input("Comment t'appelles-tu ? ") +print("Enchanté,", prenom) +``` + +Exemple d'exécution (l'utilisateur tape `Camille` puis Entrée) : + +```text +Comment t'appelles-tu ? Camille +Enchanté, Camille +``` + +> 💡 Mets une espace à la fin du message de `input()` : sinon, la réponse de l'utilisateur est collée au point d'interrogation. + +#### 7.1. ⚠️ `input()` renvoie TOUJOURS une chaîne + +C'est **le** piège numéro un des débutants. Même si l'utilisateur tape un nombre, `input()` renvoie du **texte** : + +```python +nombre = input("Donne un nombre : ") +print(type(nombre)) +print(nombre * 3) +``` + +Exemple d'exécution : + +```text +Donne un nombre : 4 + +444 +``` + +`"4" * 3` répète le texte `"4"` trois fois : `"444"`. Pour faire des calculs, il faut **convertir** la réponse en nombre. + +### 8. Les conversions de type + +| Fonction | Convertit en | Exemple | Résultat | +|---|---|---|---| +| `int(...)` | entier | `int("42")` | `42` | +| `float(...)` | nombre à virgule | `float("3.5")` | `3.5` | +| `str(...)` | chaîne | `str(42)` | `"42"` | +| `bool(...)` | booléen | `bool(0)` | `False` | + +La bonne façon de lire un nombre au clavier : + +```python +age = int(input("Quel âge as-tu ? ")) +print("L'année prochaine, tu auras", age + 1, "ans.") +``` + +Exemple d'exécution : + +```text +Quel âge as-tu ? 25 +L'année prochaine, tu auras 26 ans. +``` + +Lis `int(input(...))` de l'intérieur vers l'extérieur : `input()` récupère le texte `"25"`, puis `int()` le transforme en entier `25`. + +Pour un nombre à virgule (une taille, un prix…), utilise `float()` : + +```python +taille = float(input("Ta taille en mètres : ")) +print("Ta taille en centimètres :", taille * 100) +``` + +Exemple d'exécution : + +```text +Ta taille en mètres : 1.75 +Ta taille en centimètres : 175.0 +``` + +Quelques conversions à connaître : + +```python +print(int(3.99)) # int() tronque : il coupe, il n'arrondit pas +print(int(-3.99)) +print(int(" 42 ")) # les espaces autour sont tolérés +print(float("7")) +print(float(10)) +print(str(3.5) + " €") # pour coller un nombre à du texte +``` + +Sortie : + +```text +3 +-3 +42 +7.0 +10.0 +3.5 € +``` + +Et les conversions qui **échouent** : + +```python +int("3.5") # ValueError : "3.5" n'est pas l'écriture d'un entier +int("douze") # ValueError : ce n'est pas un nombre du tout +float("3,5") # ValueError : la virgule n'est pas acceptée +``` + +Chacune de ces lignes, exécutée seule, se termine par l'un de ces messages : + +```text +ValueError: invalid literal for int() with base 10: '3.5' +ValueError: invalid literal for int() with base 10: 'douze' +ValueError: could not convert string to float: '3,5' +``` + +Pour transformer `"3.5"` en entier, passe par `float` : `int(float("3.5"))` donne `3`. Et pour gérer proprement le cas où l'utilisateur tape n'importe quoi, il faudra attendre le [chapitre 08](08-erreurs-et-debogage.md) (`try` / `except`). + +**Les conversions en booléen.** `bool()` renvoie `False` pour les valeurs « vides » ou nulles (`0`, `0.0`, `""`) et `True` pour tout le reste : + +```python +print(bool(0), bool(0.0), bool("")) +print(bool(42), bool(-1), bool("bonjour"), bool("0"), bool(" ")) +``` + +Sortie : + +```text +False False False +True True True True True +``` + +Attention : `bool("0")` vaut `True`, car `"0"` est une chaîne **non vide** ! + +### 9. Les f-strings : l'affichage moderne + +Pour mélanger texte et valeurs, on a vu les virgules dans `print()` et la concaténation avec `+` (qui oblige à convertir avec `str()`). Les **f-strings** sont la méthode moderne, la plus lisible et la plus utilisée. + +Une f-string est une chaîne précédée de la lettre **`f`**. À l'intérieur, tout ce qui est entre **accolades** `{ }` est remplacé par sa valeur : + +```python +prenom = "Camille" +age = 25 +print(f"Je m'appelle {prenom} et j'ai {age} ans.") +``` + +Sortie : + +```text +Je m'appelle Camille et j'ai 25 ans. +``` + +Compare avec les autres méthodes : + +```python +prenom = "Camille" +age = 25 +print("Je m'appelle", prenom, "et j'ai", age, "ans.") +print("Je m'appelle " + prenom + " et j'ai " + str(age) + " ans.") +print(f"Je m'appelle {prenom} et j'ai {age} ans.") +``` + +Sortie : + +```text +Je m'appelle Camille et j'ai 25 ans. +Je m'appelle Camille et j'ai 25 ans. +Je m'appelle Camille et j'ai 25 ans. +``` + +Même résultat, mais la f-string est la plus facile à lire et à écrire. Pas besoin de `str()`, pas de problèmes d'espaces oubliées. + +#### 9.1. Des calculs dans les accolades + +Tu peux mettre n'importe quelle expression entre les accolades : + +```python +prix = 12.5 +quantite = 3 +print(f"{quantite} articles à {prix} € = {prix * quantite} €") +print(f"Dans 10 ans, j'aurai {25 + 10} ans.") +``` + +Sortie : + +```text +3 articles à 12.5 € = 37.5 € +Dans 10 ans, j'aurai 35 ans. +``` + +#### 9.2. Formater les nombres + +Après la valeur, on peut ajouter `:` suivi d'une **spécification de format** : + +```python +pi = 3.14159265 +print(f"{pi:.2f}") # 2 chiffres après la virgule +print(f"{pi:.0f}") # aucun chiffre après la virgule +print(f"{3 * 1.2:.2f}") # adieu 3.5999999999999996 ! +print(f"{1234567.891:,.2f}") # séparateur de milliers +print(f"{0.256:.1%}") # pourcentage +print(f"{7:03d}") # sur 3 chiffres, complété par des zéros +``` + +Sortie : + +```text +3.14 +3 +3.60 +1,234,567.89 +25.6% +007 +``` + +Le `f` de `.2f` signifie *fixed point* (nombre à virgule avec un nombre fixe de décimales) ; le `d` de `03d` signifie *decimal* (entier). + +#### 9.3. Aligner du texte + +On peut aussi réserver une **largeur** et choisir l'alignement : `<` à gauche, `>` à droite, `^` au centre. + +```python +print(f"[{'chat':<10}]") +print(f"[{'chat':>10}]") +print(f"[{'chat':^10}]") +print(f"[{'chat':*^10}]") # caractère de remplissage : * +``` + +Sortie : + +```text +[chat ] +[ chat] +[ chat ] +[***chat***] +``` + +C'est parfait pour faire des tableaux bien alignés : + +```python +article1, prix1 = "Pain", 1.1 +article2, prix2 = "Fromage", 4.75 +article3, prix3 = "Confiture", 3.2 +print(f"{'Article':<12}{'Prix':>8}") +print("-" * 20) +print(f"{article1:<12}{prix1:>8.2f}") +print(f"{article2:<12}{prix2:>8.2f}") +print(f"{article3:<12}{prix3:>8.2f}") +print("-" * 20) +print(f"{'TOTAL':<12}{prix1 + prix2 + prix3:>8.2f}") +``` + +Sortie : + +```text +Article Prix +-------------------- +Pain 1.10 +Fromage 4.75 +Confiture 3.20 +-------------------- +TOTAL 9.05 +``` + +`{prix1:>8.2f}` combine les deux : largeur 8, alignement à droite, 2 décimales. + +#### 9.4. L'astuce de débogage `=` + +En ajoutant `=` après une expression, la f-string affiche l'expression **et** sa valeur. Très pratique pour vérifier ce que contiennent tes variables : + +```python +largeur = 5 +hauteur = 3 +print(f"{largeur = }") +print(f"{largeur * hauteur = }") +``` + +Sortie : + +```text +largeur = 5 +largeur * hauteur = 15 +``` + +### 10. Mettre tout ensemble : un programme complet + +Voici un petit programme qui utilise tout ce que tu as appris : variables, saisie, conversion, calcul et affichage formaté. + +```python +# Calcul du prix d'un plein d'essence +print("=== Calculateur de plein ===") + +prix_litre = float(input("Prix du litre (en €) : ")) +capacite = float(input("Capacité du réservoir (en litres) : ")) +niveau_actuel = float(input("Litres déjà présents : ")) + +litres_a_mettre = capacite - niveau_actuel +cout = litres_a_mettre * prix_litre + +print() +print(f"Litres à mettre : {litres_a_mettre:.1f} L") +print(f"Coût du plein : {cout:.2f} €") +``` + +Exemple d'exécution : + +```text +=== Calculateur de plein === +Prix du litre (en €) : 1.85 +Capacité du réservoir (en litres) : 50 +Litres déjà présents : 12.5 + +Litres à mettre : 37.5 L +Coût du plein : 69.38 € +``` + +Remarque la structure classique d'un programme : **entrées** (`input`) → **traitement** (calculs) → **sorties** (`print`). Tu la retrouveras partout. + +--- + +## Pièges fréquents + +### Piège 1 — Oublier de convertir le résultat de `input()` + +```python +age = input("Quel âge as-tu ? ") +print(age + 1) +``` + +```text +Quel âge as-tu ? 25 +Traceback (most recent call last): + File "age.py", line 2, in + print(age + 1) + ~~~~^~~ +TypeError: can only concatenate str (not "int") to str +``` + +Python te dit qu'il ne peut coller (*concatenate*) qu'une chaîne à une chaîne, pas un entier. Normal : `age` est la chaîne `"25"`. + +**Correction :** `age = int(input("Quel âge as-tu ? "))`. + +### Piège 2 — Coller un nombre à du texte avec `+` + +```python +age = 25 +print("J'ai " + age + " ans") +``` + +```text +Traceback (most recent call last): + File "age.py", line 2, in + print("J'ai " + age + " ans") + ~~~~~~~~^~~~~ +TypeError: can only concatenate str (not "int") to str +``` + +**Correction :** utilise une f-string : `print(f"J'ai {age} ans")`. (Ou `str(age)`, mais c'est moins lisible.) + +### Piège 3 — Confondre `=` et `==` + +`=` **affecte** une valeur ; `==` **compare** deux valeurs (on le verra au chapitre 02). + +```python +x = 5 # x désigne 5 +x == 5 # pose la question « x vaut-il 5 ? » → True (mais ne change rien) +``` + +Écrire `5 = x` provoque même une erreur : on ne peut pas coller une étiquette sur un nombre. Python devine d'ailleurs ce que tu voulais faire : + +```python +5 = x +``` + +```text + File "egalite.py", line 1 + 5 = x + ^ +SyntaxError: cannot assign to literal here. Maybe you meant '==' instead of '='? +``` + +### Piège 4 — Mettre des guillemets autour d'une variable + +```python +prenom = "Camille" +print("prenom") +print(f"Bonjour prenom") +``` + +Sortie : + +```text +prenom +Bonjour prenom +``` + +Entre guillemets, `prenom` est un texte comme un autre, pas la variable. + +**Correction :** `print(prenom)` et `print(f"Bonjour {prenom}")`. Et n'oublie pas le `f` devant la f-string : sans lui, `"Bonjour {prenom}"` affiche littéralement les accolades. + +### Piège 5 — Écrire un nombre avec une virgule + +```python +prix = 3,5 +print(prix) +print(type(prix)) +``` + +Sortie : + +```text +(3, 5) + +``` + +Pas d'erreur, mais pas du tout ce qu'on voulait : Python a créé un **tuple** (deux valeurs, `3` et `5`, qu'on verra au [chapitre 04](04-structures-de-donnees.md)). De même, `float("3,5")` provoque `ValueError: could not convert string to float: '3,5'`. + +**Correction :** toujours un point décimal : `prix = 3.5`. + +### Piège 6 — `int()` sur un texte qui contient une virgule ou un point + +```python +note = int(input("Ta note : ")) +``` + +```text +Ta note : 14.5 +Traceback (most recent call last): + File "note.py", line 1, in + note = int(input("Ta note : ")) +ValueError: invalid literal for int() with base 10: '14.5' +``` + +**Correction :** si la valeur peut avoir des décimales, utilise `float(input(...))`. + +### Piège 7 — Un nom de variable invalide + +```python +2eme_joueur = "Léa" +``` + +```text + File "joueurs.py", line 1 + 2eme_joueur = "Léa" + ^ +SyntaxError: invalid decimal literal +``` + +```python +mon score = 10 +``` + +```text + File "score.py", line 1 + mon score = 10 + ^^^^^ +SyntaxError: invalid syntax +``` + +**Correction :** `joueur_2 = "Léa"` et `mon_score = 10`. + +### Piège 8 — Écraser une fonction intégrée + +```python +print = "Bonjour" +print("Salut") +``` + +```text +Traceback (most recent call last): + File "ecrase.py", line 2, in + print("Salut") + ~~~~~^^^^^^^^^ +TypeError: 'str' object is not callable +``` + +En écrivant `print = ...`, tu as collé l'étiquette `print` sur une chaîne : la fonction d'affichage n'est plus accessible ! Même chose avec `input`, `int`, `str`, `type`, `len`, `max`, `min`, `sum`… + +**Correction :** choisis un autre nom (`message = "Bonjour"`). Si c'est arrivé dans l'interpréteur interactif, quitte-le et relance-le. + +--- + +## Exercices + +### Exercice 1 ⭐ — Prédire l'affichage + +Sans exécuter le code, écris sur papier ce qu'il va afficher. Vérifie ensuite en l'exécutant. + +```python +a = 7 +b = 2 +c = a // b +d = a % b +a = a + b +b *= 3 +print(a, b, c, d) +print(a / 3, type(a / 3)) +print("7" + "2", 7 + 2, "7" * 2) +``` + +### Exercice 2 ⭐ — Celsius vers Fahrenheit + +Écris un programme qui demande une température en degrés Celsius et l'affiche en degrés Fahrenheit, avec une décimale. Formule : F = C × 9 / 5 + 32. + +Exemple d'exécution : + +```text +Température en °C : 21.5 +21.5 °C = 70.7 °F +``` + +### Exercice 3 ⭐ — Le rectangle + +Écris un programme qui demande la largeur et la longueur d'un rectangle (nombres à virgule possibles) et affiche son périmètre et son aire. + +Exemple d'exécution : + +```text +Largeur : 4 +Longueur : 6.5 +Périmètre : 21.0 +Aire : 26.0 +``` + +### Exercice 4 ⭐⭐ — Convertisseur de durée + +Écris un programme qui demande une durée en secondes (un entier) et l'affiche en heures, minutes et secondes, avec `//` et `%`. + +Exemple d'exécution : + +```text +Durée en secondes : 7384 +7384 secondes = 2 h 3 min 4 s +``` + +### Exercice 5 ⭐⭐ — Somme des chiffres + +Écris un programme qui demande un nombre entier à **trois chiffres** et affiche séparément ses chiffres des centaines, des dizaines et des unités, puis leur somme. Interdiction de traiter le nombre comme du texte : utilise uniquement `//` et `%`. + +Exemple d'exécution : + +```text +Nombre à 3 chiffres : 472 +Centaines : 4 +Dizaines : 7 +Unités : 2 +Somme des chiffres : 13 +``` + +### Exercice 6 ⭐⭐ — Carte d'identité formatée + +Écris un programme qui demande le prénom, le nom, l'année de naissance et la taille (en mètres) d'une personne, puis affiche une fiche alignée comme ci-dessous, avec l'âge calculé à partir de l'année en cours (stockée dans une variable `annee_actuelle`, ici 2026). Le cadre fait 30 caractères de large. + +Exemple d'exécution : + +```text +Prénom : Ada +Nom : Lovelace +Année de naissance : 1990 +Taille (m) : 1.68 +****************************** +* Prénom : Ada * +* Nom : Lovelace * +* Âge : 36 ans * +* Taille : 168 cm * +****************************** +``` + +Indice : `{prenom:>16}` réserve 16 caractères alignés à droite. Pour la taille en centimètres, pense à `round()`. + +### Exercice 7 ⭐⭐⭐ — Partage de l'addition + +Un groupe d'amis va au restaurant. Écris un programme qui demande le montant de l'addition, le pourcentage de pourboire souhaité (un entier, par exemple `10` pour 10 %) et le nombre de personnes. Il affiche le montant du pourboire, le total et la part de chacun, arrondis au centime. + +Exemple d'exécution : + +```text +Montant de l'addition (€) : 87.40 +Pourboire (%) : 10 +Nombre de personnes : 4 +Pourboire : 8.74 € +Total : 96.14 € +Par personne : 24.04 € +``` + +### Exercice 8 ⭐⭐⭐ — Le rendu de monnaie + +Un distributeur doit rendre une somme entière en euros avec le moins de billets et de pièces possible. Les coupures disponibles sont : billets de 50, 20, 10 et 5 €, pièces de 2 et 1 €. Écris un programme qui demande la somme à rendre et affiche le nombre de chaque coupure. (Tu n'as pas encore les conditions, donc affiche aussi les coupures dont le nombre est 0.) + +Exemple d'exécution : + +```text +Somme à rendre (€) : 188 +Billets de 50 € : 3 +Billets de 20 € : 1 +Billets de 10 € : 1 +Billets de 5 € : 1 +Pièces de 2 € : 1 +Pièces de 1 € : 1 +``` + +Indice : après avoir calculé le nombre de billets de 50 avec `//`, la somme restant à rendre s'obtient avec `%`. + +--- + +## Mini-projet + +### Le calculateur de budget de vacances + +**Objectif :** écrire un programme `budget_vacances.py` qui aide à estimer le coût d'un séjour et la part de chaque participant. + +**Cahier des charges :** + +1. Le programme affiche un titre, puis demande : + - la destination (texte) ; + - le nombre de participants (entier) ; + - le nombre de nuits (entier) ; + - le prix d'une nuit de logement pour **tout le groupe** (float) ; + - le budget repas par personne et par jour (float) ; + - le coût du transport **par personne** (float). +2. Il calcule : + - le coût total du logement ; + - le coût total des repas (on compte un jour de repas par nuit) ; + - le coût total du transport ; + - le coût total du séjour et le coût par personne ; + - combien chaque personne doit épargner **par semaine** si le départ est dans un nombre de semaines saisi au clavier. +3. Il affiche un récapitulatif sous forme de tableau aligné, avec les montants à 2 décimales et alignés à droite, et la destination en majuscules dans le titre du récapitulatif (indice : `destination.upper()` renvoie la chaîne en majuscules ; les méthodes de chaînes seront détaillées au chapitre 06). +4. Le code est commenté, en trois blocs : entrées, calculs, affichage. + +**Exemple d'exécution :** + +```text +=== Budget vacances === +Destination : Lisbonne +Nombre de participants : 4 +Nombre de nuits : 5 +Prix d'une nuit pour le groupe (€) : 140 +Budget repas par personne et par jour (€) : 35 +Transport par personne (€) : 120 +Départ dans combien de semaines ? 10 + +======= RÉCAPITULATIF : LISBONNE ======= +Logement 700.00 € +Repas 700.00 € +Transport 480.00 € +---------------------------------------- +TOTAL 1880.00 € +Par personne 470.00 € +Épargne par semaine 47.00 € +======================================== +``` + +**Étapes suggérées :** + +1. Écris d'abord tous les `input()` avec les bonnes conversions, et affiche les variables avec l'astuce `f"{variable = }"` pour vérifier. +2. Écris les calculs, un par variable, avec des noms clairs (`cout_logement`, `cout_repas`…). +3. Construis l'affichage ligne par ligne. Commence sans alignement, puis ajoute les spécifications de format (`:<25`, `:>10.2f`). +4. Teste avec plusieurs jeux de valeurs et vérifie un résultat à la main. + +**Pistes d'amélioration :** + +- Ajoute une ligne « Activités » avec un budget par personne pour tout le séjour. +- Affiche la part de chaque poste en pourcentage du total (`:.1%`). +- Ajoute une marge de sécurité de 10 % pour les imprévus. +- Au chapitre 02, tu pourras vérifier que le nombre de participants n'est pas nul et comparer avec un budget maximum ; au chapitre 03, tu pourras demander le détail de chaque participant. + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +Le code de l'énoncé affiche : + +```text +9 6 3 1 +3.0 +72 9 77 +``` + +Explication pas à pas : + +- `c = 7 // 2` → `3` et `d = 7 % 2` → `1` (calculés **avant** que `a` et `b` changent) ; +- `a = 7 + 2` → `9`, puis `b *= 3` → `b = 2 * 3` → `6` ; +- `a / 3` → `9 / 3` → `3.0` : la division `/` donne toujours un `float` ; +- `"7" + "2"` colle deux textes → `72` ; `7 + 2` additionne → `9` ; `"7" * 2` répète le texte → `77`. + +
+ +
+Corrigé de l'exercice 2 + +```python +# Conversion Celsius -> Fahrenheit +celsius = float(input("Température en °C : ")) +fahrenheit = celsius * 9 / 5 + 32 +print(f"{celsius} °C = {fahrenheit:.1f} °F") +``` + +Exemple d'exécution : + +```text +Température en °C : 21.5 +21.5 °C = 70.7 °F +``` + +On utilise `float()` car une température peut avoir des décimales. Les priorités font que `celsius * 9 / 5` est calculé avant `+ 32`, comme dans la formule. + +
+ +
+Corrigé de l'exercice 3 + +```python +# Périmètre et aire d'un rectangle +largeur = float(input("Largeur : ")) +longueur = float(input("Longueur : ")) + +perimetre = 2 * (largeur + longueur) +aire = largeur * longueur + +print(f"Périmètre : {perimetre}") +print(f"Aire : {aire}") +``` + +Exemple d'exécution : + +```text +Largeur : 4 +Longueur : 6.5 +Périmètre : 21.0 +Aire : 26.0 +``` + +Les parenthèses dans `2 * (largeur + longueur)` sont indispensables : sans elles, on calculerait `2 * largeur + longueur`. + +
+ +
+Corrigé de l'exercice 4 + +```python +# Conversion d'une durée en secondes vers h / min / s +duree = int(input("Durée en secondes : ")) + +heures = duree // 3600 # 3600 secondes dans une heure +reste = duree % 3600 # secondes qui ne font pas une heure complète +minutes = reste // 60 +secondes = reste % 60 + +print(f"{duree} secondes = {heures} h {minutes} min {secondes} s") +``` + +Exemple d'exécution : + +```text +Durée en secondes : 7384 +7384 secondes = 2 h 3 min 4 s +``` + +Vérification : 2 × 3600 + 3 × 60 + 4 = 7200 + 180 + 4 = 7384. ✔️ + +Variante sans la variable `reste` : `minutes = duree // 60 % 60` et `secondes = duree % 60`. + +
+ +
+Corrigé de l'exercice 5 + +```python +# Décomposer un nombre à trois chiffres +nombre = int(input("Nombre à 3 chiffres : ")) + +centaines = nombre // 100 # 472 // 100 -> 4 +dizaines = nombre // 10 % 10 # 472 // 10 -> 47, puis 47 % 10 -> 7 +unites = nombre % 10 # 472 % 10 -> 2 + +print(f"Centaines : {centaines}") +print(f"Dizaines : {dizaines}") +print(f"Unités : {unites}") +print(f"Somme des chiffres : {centaines + dizaines + unites}") +``` + +Exemple d'exécution : + +```text +Nombre à 3 chiffres : 472 +Centaines : 4 +Dizaines : 7 +Unités : 2 +Somme des chiffres : 13 +``` + +`//` et `%` ont la même priorité et s'évaluent de gauche à droite : `nombre // 10 % 10` signifie `(nombre // 10) % 10`. + +
+ +
+Corrigé de l'exercice 6 + +```python +# Fiche d'identité formatée +annee_actuelle = 2026 + +prenom = input("Prénom : ") +nom = input("Nom : ") +annee_naissance = int(input("Année de naissance : ")) +taille = float(input("Taille (m) : ")) + +age = annee_actuelle - annee_naissance +taille_cm = round(taille * 100) + +print("*" * 30) +print(f"* Prénom : {prenom:>16} *") +print(f"* Nom : {nom:>16} *") +print(f"* Âge : {age:>11} ans *") +print(f"* Taille : {taille_cm:>12} cm *") +print("*" * 30) +``` + +Exemple d'exécution : + +```text +Prénom : Ada +Nom : Lovelace +Année de naissance : 1990 +Taille (m) : 1.68 +****************************** +* Prénom : Ada * +* Nom : Lovelace * +* Âge : 36 ans * +* Taille : 168 cm * +****************************** +``` + +Pourquoi `round()` ? D'abord pour obtenir un entier (`168` plutôt que `168.0`). Ensuite à cause de l'imprécision des floats : avec une taille de `1.15`, le calcul `1.15 * 100` donne `114.99999999999999`, et `int()` (qui tronque) donnerait `114` ! `round()` donne l'entier le plus proche, `115`. Chaque ligne fait bien 30 caractères : 11 caractères avant la valeur, la largeur réservée, puis la fin de ligne. + +
+ +
+Corrigé de l'exercice 7 + +```python +# Partage de l'addition au restaurant +addition = float(input("Montant de l'addition (€) : ")) +pourcentage = int(input("Pourboire (%) : ")) +nb_personnes = int(input("Nombre de personnes : ")) + +pourboire = addition * pourcentage / 100 +total = addition + pourboire +part = total / nb_personnes + +print(f"Pourboire : {pourboire:.2f} €") +print(f"Total : {total:.2f} €") +print(f"Par personne : {part:.2f} €") +``` + +Exemple d'exécution : + +```text +Montant de l'addition (€) : 87.40 +Pourboire (%) : 10 +Nombre de personnes : 4 +Pourboire : 8.74 € +Total : 96.14 € +Par personne : 24.04 € +``` + +On garde les valeurs exactes dans les variables et on n'arrondit qu'**à l'affichage** avec `:.2f` : arrondir trop tôt accumule les erreurs. Si l'utilisateur tape `0` personne, le programme plante avec `ZeroDivisionError` : on apprendra à l'en empêcher au chapitre 02. + +
+ +
+Corrigé de l'exercice 8 + +```python +# Rendu de monnaie avec le moins de coupures possible +somme = int(input("Somme à rendre (€) : ")) + +billets_50 = somme // 50 +somme = somme % 50 # ce qui reste à rendre + +billets_20 = somme // 20 +somme %= 20 # même chose, écrit en raccourci + +billets_10 = somme // 10 +somme %= 10 + +billets_5 = somme // 5 +somme %= 5 + +pieces_2 = somme // 2 +pieces_1 = somme % 2 + +print(f"Billets de 50 € : {billets_50}") +print(f"Billets de 20 € : {billets_20}") +print(f"Billets de 10 € : {billets_10}") +print(f"Billets de 5 € : {billets_5}") +print(f"Pièces de 2 € : {pieces_2}") +print(f"Pièces de 1 € : {pieces_1}") +``` + +Exemple d'exécution : + +```text +Somme à rendre (€) : 188 +Billets de 50 € : 3 +Billets de 20 € : 1 +Billets de 10 € : 1 +Billets de 5 € : 1 +Pièces de 2 € : 1 +Pièces de 1 € : 1 +``` + +Vérification : 3 × 50 + 20 + 10 + 5 + 2 + 1 = 188. ✔️ La méthode est toujours la même : `//` donne le nombre de coupures, `%` donne ce qui reste à rendre. Tu remarques la répétition ? Au chapitre 03, une boucle fera ce travail en quelques lignes. + +
+ +
+Corrigé du mini-projet + +```python +# budget_vacances.py +# Estime le coût d'un séjour et la part de chaque participant. + +# ---------- Entrées ---------- +print("=== Budget vacances ===") +destination = input("Destination : ") +nb_participants = int(input("Nombre de participants : ")) +nb_nuits = int(input("Nombre de nuits : ")) +prix_nuit_groupe = float(input("Prix d'une nuit pour le groupe (€) : ")) +repas_par_jour = float(input("Budget repas par personne et par jour (€) : ")) +transport_par_personne = float(input("Transport par personne (€) : ")) +nb_semaines = int(input("Départ dans combien de semaines ? ")) + +# ---------- Calculs ---------- +cout_logement = nb_nuits * prix_nuit_groupe +cout_repas = nb_participants * nb_nuits * repas_par_jour +cout_transport = nb_participants * transport_par_personne +cout_total = cout_logement + cout_repas + cout_transport +cout_par_personne = cout_total / nb_participants +epargne_hebdo = cout_par_personne / nb_semaines + +# ---------- Affichage ---------- +largeur = 40 +titre = f" RÉCAPITULATIF : {destination.upper()} " +print() +print(f"{titre:=^{largeur}}") +print(f"{'Logement':<25}{cout_logement:>13.2f} €") +print(f"{'Repas':<25}{cout_repas:>13.2f} €") +print(f"{'Transport':<25}{cout_transport:>13.2f} €") +print("-" * largeur) +print(f"{'TOTAL':<25}{cout_total:>13.2f} €") +print(f"{'Par personne':<25}{cout_par_personne:>13.2f} €") +print(f"{'Épargne par semaine':<25}{epargne_hebdo:>13.2f} €") +print("=" * largeur) +``` + +Exemple d'exécution : + +```text +=== Budget vacances === +Destination : Lisbonne +Nombre de participants : 4 +Nombre de nuits : 5 +Prix d'une nuit pour le groupe (€) : 140 +Budget repas par personne et par jour (€) : 35 +Transport par personne (€) : 120 +Départ dans combien de semaines ? 10 + +======= RÉCAPITULATIF : LISBONNE ======= +Logement 700.00 € +Repas 700.00 € +Transport 480.00 € +---------------------------------------- +TOTAL 1880.00 € +Par personne 470.00 € +Épargne par semaine 47.00 € +======================================== +``` + +Remarques : + +- Chaque ligne du tableau fait 25 + 13 + 2 = 40 caractères, comme le cadre. +- `{titre:=^{largeur}}` centre le titre sur 40 caractères en complétant avec des `=`. On peut mettre une variable (`{largeur}`) à l'intérieur de la spécification de format. +- Stocker la largeur dans une variable permet de changer la taille du tableau à un seul endroit. + +
+ +--- + +## Récapitulatif + +**Points clés :** + +- Une **variable** est un nom qui désigne une valeur. `=` affecte (de droite à gauche), il ne compare pas. +- Noms en `snake_case`, parlants, sans accents ; jamais de chiffre au début, jamais d'espace, jamais de mot réservé. +- Quatre types de base : `int` (entier), `float` (à virgule, avec un **point**), `str` (texte entre guillemets), `bool` (`True` / `False`). `type()` donne le type d'une valeur. +- Opérateurs : `+ - * /` (toujours un float), `//` (quotient entier), `%` (reste), `**` (puissance). Les priorités sont celles des maths ; dans le doute, des parenthèses. +- Raccourcis : `+=`, `-=`, `*=`, `/=`… (pas de `++` en Python). +- Sur les chaînes : `+` colle, `*` répète, `len()` compte les caractères, `\n` passe à la ligne. +- `input()` renvoie **toujours** une chaîne : convertis avec `int()` ou `float()` avant de calculer. +- Les **f-strings** `f"... {expression} ..."` sont la meilleure façon d'afficher : `:.2f` pour les décimales, `:<10` / `:>10` / `:^10` pour aligner, `{x = }` pour déboguer. +- Les floats sont parfois imprécis (`0.1 + 0.2`) : arrondis à l'affichage. + +**Checklist de validation :** + +- [ ] Je sais créer une variable, la modifier et l'afficher. +- [ ] Je sais dire si un nom de variable est valide et s'il respecte les conventions. +- [ ] Je connais les types `int`, `float`, `str`, `bool` et je sais vérifier un type avec `type()`. +- [ ] Je sais utiliser `//` et `%` pour découper une quantité (durée, monnaie, chiffres). +- [ ] Je convertis systématiquement le résultat de `input()` quand j'attends un nombre. +- [ ] Je sais écrire une f-string avec un nombre de décimales et un alignement. +- [ ] J'ai réalisé le mini-projet et vérifié un résultat à la main. + +--- + +[← Chapitre précédent](00-installation-environnement.md) | [Sommaire](README.md) | [Chapitre suivant →](02-conditions.md) diff --git a/docs/presentations/Python/02-conditions.md b/docs/presentations/Python/02-conditions.md new file mode 100644 index 0000000..dc35c1b --- /dev/null +++ b/docs/presentations/Python/02-conditions.md @@ -0,0 +1,1680 @@ +# Chapitre 02 — Les conditions : faire des choix + +> **⏱️ Durée estimée :** 4 heures +> +> **📋 Prérequis :** [Chapitre 01 — Variables, types et opérateurs](01-variables-types-operateurs.md) : variables, types `int`/`float`/`str`/`bool`, `input()` avec conversion, f-strings. +> +> **🎯 Objectifs — À la fin de ce chapitre, tu sauras :** +> - comparer des valeurs avec `==`, `!=`, `<`, `>`, `<=`, `>=` et obtenir un booléen ; +> - faire exécuter du code seulement si une condition est vraie, avec `if`, `elif` et `else` ; +> - indenter correctement ton code pour délimiter des blocs ; +> - combiner plusieurs conditions avec `and`, `or` et `not` ; +> - utiliser l'expression conditionnelle sur une ligne (`a if condition else b`) ; +> - choisir parmi plusieurs cas de façon élégante avec `match` / `case`. + +--- + +## Cours + +### 1. Pourquoi des conditions ? + +Jusqu'ici, tes programmes exécutaient toutes leurs lignes, de haut en bas, quoi qu'il arrive. Mais la vie est faite de choix : **s'il** pleut, je prends un parapluie, **sinon** je prends mes lunettes de soleil. Un distributeur de billets vérifie **si** ton code est correct. Un jeu vérifie **si** tu as perdu toutes tes vies. + +Les **conditions** permettent à un programme de prendre des décisions : exécuter certaines lignes seulement dans certaines situations. Pour cela, il faut d'abord savoir poser des questions dont la réponse est **oui** ou **non**, c'est-à-dire `True` ou `False`. + +### 2. Les opérateurs de comparaison + +Un opérateur de comparaison compare deux valeurs et renvoie un **booléen** (`True` ou `False`). + +| Opérateur | Signification | Exemple | Résultat | +|---|---|---|---| +| `==` | est égal à | `5 == 5` | `True` | +| `!=` | est différent de | `5 != 3` | `True` | +| `<` | est strictement inférieur à | `3 < 5` | `True` | +| `>` | est strictement supérieur à | `3 > 5` | `False` | +| `<=` | est inférieur ou égal à | `5 <= 5` | `True` | +| `>=` | est supérieur ou égal à | `4 >= 5` | `False` | + +```python +age = 17 +print(age >= 18) +print(age == 17) +print(age != 17) + +majeur = age >= 18 # on peut stocker le résultat dans une variable +print(majeur, type(majeur)) +``` + +Sortie : + +```text +False +True +False +False +``` + +> ⚠️ **`=` n'est pas `==` !** Un seul `=` **affecte** une valeur (« colle l'étiquette »). Deux `==` **comparent** (« est-ce que c'est égal ? »). C'est l'erreur la plus fréquente de ce chapitre. + +#### 2.1. Comparer des chaînes + +On peut comparer des chaînes. L'égalité est **stricte** : majuscules, espaces et accents comptent. + +```python +print("chat" == "chat") +print("chat" == "Chat") +print("chat" == "chat ") +print("été" == "ete") +``` + +Sortie : + +```text +True +False +False +False +``` + +On peut aussi comparer des chaînes avec `<` et `>` : c'est l'ordre « alphabétique », mais attention, il suit en réalité les codes des caractères. Toutes les majuscules passent avant toutes les minuscules, et les chiffres sont comparés **caractère par caractère** : + +```python +print("abricot" < "banane") +print("Zoo" < "abc") +print("10" < "9") +``` + +Sortie : + +```text +True +True +True +``` + +`"10" < "9"` est vrai, car on compare d'abord `"1"` et `"9"`, et `"1"` vient avant. Encore une bonne raison de convertir les saisies en nombres avec `int()` ! + +#### 2.2. Comparer des nombres à virgule + +À cause de l'imprécision des floats vue au chapitre 01, l'égalité exacte entre floats est piégeuse : + +```python +print(0.1 + 0.2 == 0.3) +print(abs((0.1 + 0.2) - 0.3) < 0.000001) +``` + +Sortie : + +```text +False +True +``` + +Pour comparer deux floats, on vérifie plutôt que leur écart est minuscule. (Le module `math` propose `math.isclose()` pour ça, voir le [chapitre 09](09-modules-bibliotheque-standard.md).) + +#### 2.3. Comparaisons enchaînées + +Python permet d'écrire des encadrements comme en maths : + +```python +note = 14 +print(10 <= note <= 20) # note est-elle entre 10 et 20 ? +print(0 < note < 10) +``` + +Sortie : + +```text +True +False +``` + +`10 <= note <= 20` signifie « `10 <= note` **et** `note <= 20` ». C'est très lisible, profites-en. + +### 3. L'instruction `if` + +Voici la structure de base : + +```python +temperature = 31 + +if temperature > 30: + print("Il fait très chaud !") + print("Pense à boire de l'eau.") + +print("Bonne journée.") +``` + +Sortie : + +```text +Il fait très chaud ! +Pense à boire de l'eau. +Bonne journée. +``` + +Décortiquons : + +1. Le mot-clé **`if`** (« si »), suivi d'une **condition** (`temperature > 30`), suivie de **deux-points** `:`. +2. En dessous, un **bloc** de lignes **indentées** (décalées vers la droite de 4 espaces). Ces lignes ne s'exécutent **que si** la condition vaut `True`. +3. La première ligne qui n'est plus indentée (`print("Bonne journée.")`) marque la fin du bloc : elle s'exécute dans tous les cas. + +Avec `temperature = 20`, la condition est fausse : le bloc est sauté. + +```python +temperature = 20 + +if temperature > 30: + print("Il fait très chaud !") + print("Pense à boire de l'eau.") + +print("Bonne journée.") +``` + +Sortie : + +```text +Bonne journée. +``` + +#### 3.1. L'indentation : la grammaire de Python + +Dans beaucoup de langages, on délimite les blocs avec des accolades `{ }`. Python, lui, utilise **l'indentation** : le décalage en début de ligne. Ce n'est pas une simple question d'esthétique : c'est **obligatoire**, et c'est ce qui dit à Python quelles lignes font partie du bloc. + +Pense à une recette : « Si tu as des invités : mets la table, sors les verres. » Les actions « mets la table » et « sors les verres » sont en retrait, car elles dépendent du « si ». + +Règles d'or : + +- la convention est d'indenter avec **4 espaces** (VS Code le fait automatiquement quand tu appuies sur Tab ou après les `:`) ; +- toutes les lignes d'un même bloc ont **exactement** la même indentation ; +- ne mélange jamais espaces et tabulations. + +Compare ces deux programmes, qui ne diffèrent que par l'indentation de la dernière ligne : + +```python +solde = 50 +if solde < 0: + print("Attention : compte à découvert !") + print("Contacte ta banque.") +``` + +Ce premier programme n'affiche **rien** : la condition est fausse, donc les deux `print` sont sautés. + +```python +solde = 50 +if solde < 0: + print("Attention : compte à découvert !") +print("Contacte ta banque.") +``` + +Sortie : + +```text +Contacte ta banque. +``` + +Dans le second programme, le dernier `print` ne fait plus partie du bloc : il s'exécute toujours. + +#### 3.2. Un bloc vide : `pass` + +Un bloc ne peut pas être vide. Si tu veux prévoir un `if` sans encore savoir quoi mettre dedans, écris `pass` (« ne rien faire ») : + +```python +niveau = 3 +if niveau > 10: + pass # TODO : gérer les niveaux bonus plus tard +print("Partie lancée") +``` + +Sortie : + +```text +Partie lancée +``` + +### 4. `else` : sinon… + +`else` (« sinon ») introduit le bloc à exécuter quand la condition est **fausse**. Exactement l'un des deux blocs est exécuté, jamais les deux, jamais aucun. + +```python +age = int(input("Quel âge as-tu ? ")) + +if age >= 18: + print("Tu es majeur.") +else: + print("Tu es mineur.") + print(f"Encore {18 - age} an(s) à attendre.") +``` + +Exemple d'exécution : + +```text +Quel âge as-tu ? 15 +Tu es mineur. +Encore 3 an(s) à attendre. +``` + +Le `else` est aligné avec le `if`, se termine par `:`, et n'a **pas** de condition. + +Un grand classique : tester si un nombre est pair, avec le modulo vu au chapitre 01. + +```python +nombre = int(input("Un nombre entier : ")) + +if nombre % 2 == 0: + print(f"{nombre} est pair.") +else: + print(f"{nombre} est impair.") +``` + +Exemple d'exécution : + +```text +Un nombre entier : 7 +7 est impair. +``` + +### 5. `elif` : sinon, si… + +Quand il y a plus de deux possibilités, on enchaîne des `elif` (contraction de *else if*, « sinon, si »). + +```python +note = float(input("Ta note sur 20 : ")) + +if note >= 16: + mention = "Très bien" +elif note >= 14: + mention = "Bien" +elif note >= 12: + mention = "Assez bien" +elif note >= 10: + mention = "Passable" +else: + mention = "Non admis" + +print(f"Mention : {mention}") +``` + +Exemple d'exécution : + +```text +Ta note sur 20 : 14.5 +Mention : Bien +``` + +**Comment Python lit ce code :** il teste les conditions **dans l'ordre, de haut en bas**, et exécute **uniquement le premier bloc** dont la condition est vraie. Ensuite, il saute directement après toute la structure, sans regarder les autres. + +Avec `14.5` : `14.5 >= 16` est faux → on passe ; `14.5 >= 14` est vrai → mention « Bien », et c'est fini. Peu importe que `14.5 >= 12` soit aussi vrai : on ne le teste même pas. + +C'est pour cela que **l'ordre des conditions est crucial**. Regarde ce qui se passe si on les inverse : + +```python +note = 18 + +if note >= 10: + mention = "Passable" +elif note >= 16: + mention = "Très bien" +else: + mention = "Non admis" + +print(mention) +``` + +Sortie : + +```text +Passable +``` + +Un 18 qui obtient « Passable » ! La première condition vraie gagne, et `18 >= 10` est vrai. Quand les conditions se chevauchent, va **du plus restrictif au moins restrictif**. + +**Résumé de la structure complète :** + +```text +if condition_1: ← obligatoire, toujours en premier + ... +elif condition_2: ← facultatif, autant qu'on veut + ... +elif condition_3: + ... +else: ← facultatif, toujours en dernier, sans condition + ... +``` + +> 💡 **`if` successifs ou `elif` ?** Plusieurs `if` à la suite sont **indépendants** : chacun est testé. Une chaîne `if`/`elif` est **exclusive** : un seul bloc s'exécute. + +```python +nombre = 15 + +print("Avec des if séparés :") +if nombre % 3 == 0: + print(" divisible par 3") +if nombre % 5 == 0: + print(" divisible par 5") + +print("Avec elif :") +if nombre % 3 == 0: + print(" divisible par 3") +elif nombre % 5 == 0: + print(" divisible par 5") +``` + +Sortie : + +```text +Avec des if séparés : + divisible par 3 + divisible par 5 +Avec elif : + divisible par 3 +``` + +### 6. Les conditions imbriquées + +Un bloc peut contenir lui-même un `if`. On parle de conditions **imbriquées**. Chaque niveau ajoute 4 espaces d'indentation. + +```python +a_un_billet = True +age = 10 + +if a_un_billet: + print("Bienvenue au parc !") + if age < 12: + print("Voici ton bracelet enfant.") + else: + print("Voici ton bracelet adulte.") +else: + print("Merci de passer par la billetterie.") +``` + +Sortie : + +```text +Bienvenue au parc ! +Voici ton bracelet enfant. +``` + +Remarque `if a_un_billet:` : comme `a_un_billet` est déjà un booléen, inutile d'écrire `if a_un_billet == True:`. C'est plus court et plus naturel (« si on a un billet »). + +> 💡 Au-delà de deux ou trois niveaux d'imbrication, le code devient difficile à lire. Les opérateurs logiques de la section suivante permettent souvent d'« aplatir » les conditions. + +### 7. Les opérateurs logiques `and`, `or`, `not` + +Ils permettent de combiner plusieurs conditions. + +#### 7.1. `and` : les deux doivent être vraies + +```python +age = 25 +a_le_permis = True + +if age >= 18 and a_le_permis: + print("Tu peux louer une voiture.") +``` + +Sortie : + +```text +Tu peux louer une voiture. +``` + +#### 7.2. `or` : au moins une doit être vraie + +```python +jour = "dimanche" + +if jour == "samedi" or jour == "dimanche": + print("C'est le week-end !") +``` + +Sortie : + +```text +C'est le week-end ! +``` + +#### 7.3. `not` : inverse une condition + +```python +est_connecte = False + +if not est_connecte: + print("Merci de te connecter.") +``` + +Sortie : + +```text +Merci de te connecter. +``` + +#### 7.4. Tables de vérité + +| `a` | `b` | `a and b` | `a or b` | `not a` | +|---|---|---|---|---| +| `True` | `True` | `True` | `True` | `False` | +| `True` | `False` | `False` | `True` | `False` | +| `False` | `True` | `False` | `True` | `True` | +| `False` | `False` | `False` | `False` | `True` | + +Moyen mnémotechnique : `and` est **exigeant** (tout doit être vrai), `or` est **arrangeant** (une seule suffit). + +#### 7.5. Priorités et parenthèses + +L'ordre de priorité est : d'abord les comparaisons, puis `not`, puis `and`, puis `or`. Comme pour `*` et `+`, `and` passe avant `or`. Dans le doute, **mets des parenthèses** : + +```python +age = 15 +accompagne = False +est_vip = True + +# Entrée autorisée si : (majeur OU accompagné) ET VIP +if (age >= 18 or accompagne) and est_vip: + print("Entrée autorisée (version avec parenthèses)") +else: + print("Entrée refusée (version avec parenthèses)") + +# Sans parenthèses, Python lit : age >= 18 or (accompagne and est_vip) +if age >= 18 or accompagne and est_vip: + print("Entrée autorisée (version sans parenthèses)") +else: + print("Entrée refusée (version sans parenthèses)") +``` + +Sortie : + +```text +Entrée refusée (version avec parenthèses) +Entrée refusée (version sans parenthèses) +``` + +Ici les deux résultats coïncident par hasard, mais changeons une valeur : + +```python +age = 20 +accompagne = False +est_vip = False + +if (age >= 18 or accompagne) and est_vip: + print("Avec parenthèses : autorisé") +else: + print("Avec parenthèses : refusé") + +if age >= 18 or accompagne and est_vip: + print("Sans parenthèses : autorisé") +else: + print("Sans parenthèses : refusé") +``` + +Sortie : + +```text +Avec parenthèses : refusé +Sans parenthèses : autorisé +``` + +Les parenthèses changent le sens. Écris toujours ce que tu veux dire explicitement. + +#### 7.6. L'évaluation paresseuse + +Python est « paresseux » : avec `and`, si la première condition est fausse, il ne regarde même pas la seconde (le résultat sera faux de toute façon). Avec `or`, si la première est vraie, il s'arrête aussi. C'est utile pour se protéger d'une erreur : + +```python +nb_personnes = 0 +total = 120 + +if nb_personnes != 0 and total / nb_personnes > 50: + print("Repas cher !") +else: + print("Pas de calcul possible ou repas raisonnable.") +``` + +Sortie : + +```text +Pas de calcul possible ou repas raisonnable. +``` + +Comme `nb_personnes != 0` est faux, la division par zéro n'est jamais tentée. + +### 8. La « vérité » des valeurs + +Une condition n'a pas forcément besoin d'être un booléen. Python sait évaluer n'importe quelle valeur comme vraie ou fausse, selon les règles de `bool()` vues au chapitre 01 : + +- **considérés comme faux** : `False`, `0`, `0.0`, la chaîne vide `""` (et `None`, ainsi que les collections vides, que tu verras plus tard) ; +- **considéré comme vrai** : tout le reste. + +```python +pseudo = input("Ton pseudo (laisse vide pour rester anonyme) : ") + +if pseudo: + print(f"Bienvenue, {pseudo} !") +else: + print("Bienvenue, visiteur anonyme !") +``` + +Exemple d'exécution (l'utilisateur appuie directement sur Entrée) : + +```text +Ton pseudo (laisse vide pour rester anonyme) : +Bienvenue, visiteur anonyme ! +``` + +`if pseudo:` se lit « si le pseudo n'est pas vide ». C'est une écriture très courante en Python. + +### 9. L'opérateur `in` avec les chaînes + +`in` teste si un morceau de texte est **contenu** dans une chaîne. C'est une façon très pratique d'éviter les longues séries de `or` : + +```python +email = "camille@exemple.fr" + +if "@" in email: + print("L'adresse contient bien un @.") + +lettre = "e" +if lettre in "aeiouy": + print(f"'{lettre}' est une voyelle.") + +if "z" not in email: + print("Pas de z dans l'adresse.") +``` + +Sortie : + +```text +L'adresse contient bien un @. +'e' est une voyelle. +Pas de z dans l'adresse. +``` + +`in` fonctionne aussi avec les listes, les tuples et les dictionnaires, que tu découvriras au [chapitre 04](04-structures-de-donnees.md). + +### 10. Rendre les saisies plus tolérantes + +Si tu demandes « oui ou non ? », l'utilisateur peut taper `oui`, `Oui`, `OUI` ou ` oui `… et `"Oui" == "oui"` est faux. Deux outils des chaînes (détaillés au [chapitre 06](06-chaines-et-comprehensions.md)) règlent le problème : + +- `texte.lower()` renvoie le texte en minuscules ; +- `texte.strip()` renvoie le texte sans les espaces au début et à la fin. + +On peut les enchaîner : + +```python +reponse = input("Veux-tu continuer (oui/non) ? ").strip().lower() + +if reponse == "oui": + print("C'est parti !") +elif reponse == "non": + print("À bientôt.") +else: + print("Je n'ai pas compris.") +``` + +Exemple d'exécution : + +```text +Veux-tu continuer (oui/non) ? OUI +C'est parti ! +``` + +### 11. L'expression conditionnelle (le « if sur une ligne ») + +Quand on veut simplement choisir entre **deux valeurs**, il existe une forme compacte : + +```text +valeur_si_vrai if condition else valeur_si_faux +``` + +```python +age = 16 +statut = "majeur" if age >= 18 else "mineur" +print(statut) + +nb_messages = 1 +print(f"Tu as {nb_messages} message{'s' if nb_messages > 1 else ''}.") +nb_messages = 3 +print(f"Tu as {nb_messages} message{'s' if nb_messages > 1 else ''}.") +``` + +Sortie : + +```text +mineur +Tu as 1 message. +Tu as 3 messages. +``` + +C'est l'équivalent de : + +```python +age = 16 +if age >= 18: + statut = "majeur" +else: + statut = "mineur" +print(statut) +``` + +Sortie : + +```text +mineur +``` + +> 💡 Réserve cette forme aux cas **simples**. Dès qu'il y a plus de deux possibilités ou plusieurs instructions, un `if`/`else` classique est plus lisible. + +### 12. `match` / `case` : choisir parmi plusieurs cas + +Quand tu compares **une même valeur** à une série de possibilités, une longue chaîne de `elif` devient vite lourde : + +```python +commande = "aide" + +if commande == "jouer": + print("Lancement du jeu...") +elif commande == "aide": + print("Voici l'aide.") +elif commande == "quitter": + print("Au revoir !") +else: + print("Commande inconnue.") +``` + +Sortie : + +```text +Voici l'aide. +``` + +Depuis Python 3.10, l'instruction **`match`** permet d'écrire cela plus clairement : + +```python +commande = "aide" + +match commande: + case "jouer": + print("Lancement du jeu...") + case "aide": + print("Voici l'aide.") + case "quitter": + print("Au revoir !") + case _: + print("Commande inconnue.") +``` + +Sortie : + +```text +Voici l'aide. +``` + +Comment ça marche : + +- `match valeur:` indique la valeur à examiner ; +- chaque `case motif:` propose une possibilité. Python les teste **dans l'ordre** et exécute le **premier** qui correspond ; +- `case _:` (le tiret bas) est le cas « joker » : il correspond à **tout**. C'est l'équivalent du `else`. Mets-le toujours en dernier. + +Pense à un aiguilleur de gare : le train (la valeur) arrive, et l'aiguilleur l'envoie sur la première voie (le `case`) qui lui correspond. + +#### 12.1. Plusieurs valeurs pour un même cas : `|` + +Le symbole `|` signifie « ou » dans un `case` : + +```python +jour = "samedi" + +match jour: + case "samedi" | "dimanche": + print("Week-end : grasse matinée !") + case "lundi" | "mardi" | "mercredi" | "jeudi" | "vendredi": + print("Semaine : debout !") + case _: + print("Ce n'est pas un jour valide.") +``` + +Sortie : + +```text +Week-end : grasse matinée ! +``` + +#### 12.2. Ajouter une condition : la garde `if` + +On peut capturer la valeur dans un nom, puis ajouter une condition avec `if` après le motif. On appelle ça une **garde** : + +```python +temperature = 23 + +match temperature: + case 0: + print("Pile le point de congélation.") + case t if t < 0: + print(f"{t} °C : il gèle !") + case t if t < 25: + print(f"{t} °C : température agréable.") + case t: + print(f"{t} °C : il fait chaud !") +``` + +Sortie : + +```text +23 °C : température agréable. +``` + +Ici, `case t if t < 0:` signifie : « appelle la valeur `t`, et ce cas s'applique si `t < 0` ». Le dernier `case t:` sans garde attrape tout ce qui reste. + +> 💡 **`match` ou `if`/`elif` ?** Utilise `match` quand tu compares **une seule valeur** à des valeurs précises (commandes, choix de menu, codes). Pour des intervalles et des conditions complexes, `if`/`elif` reste souvent plus naturel. `match` est en réalité bien plus puissant (il sait décomposer des structures de données), tu en verras d'autres usages plus tard. + +### 13. Exemple complet : la calculatrice + +Récapitulons avec une petite calculatrice qui utilise `match`, des conditions imbriquées et une vérification de la division par zéro. + +```python +# Calculatrice à 4 opérations +a = float(input("Premier nombre : ")) +operateur = input("Opérateur (+, -, *, /) : ").strip() +b = float(input("Second nombre : ")) + +match operateur: + case "+": + print(f"{a} + {b} = {a + b}") + case "-": + print(f"{a} - {b} = {a - b}") + case "*" | "x": + print(f"{a} × {b} = {a * b}") + case "/": + if b == 0: + print("Erreur : division par zéro impossible.") + else: + print(f"{a} / {b} = {a / b:.4g}") + case _: + print(f"Opérateur « {operateur} » inconnu.") +``` + +Exemple d'exécution : + +```text +Premier nombre : 10 +Opérateur (+, -, *, /) : / +Second nombre : 3 +10.0 / 3.0 = 3.333 +``` + +Le format `:.4g` affiche au plus 4 chiffres significatifs, en supprimant les zéros inutiles. + +--- + +## Pièges fréquents + +### Piège 1 — Oublier les deux-points + +```python +age = 20 +if age >= 18 + print("Majeur") +``` + +```text + File "conditions.py", line 2 + if age >= 18 + ^ +SyntaxError: expected ':' +``` + +**Correction :** chaque ligne `if`, `elif`, `else`, `match` et `case` se termine par `:`. + +### Piège 2 — Oublier d'indenter + +```python +age = 20 +if age >= 18: +print("Majeur") +``` + +```text + File "conditions.py", line 3 + print("Majeur") + ^^^^^ +IndentationError: expected an indented block after 'if' statement on line 2 +``` + +**Correction :** décale la ligne de 4 espaces : ` print("Majeur")`. + +### Piège 3 — Une indentation incohérente + +```python +age = 20 +if age >= 18: + print("Majeur") + print("fin") +``` + +```text + File "conditions.py", line 4 + print("fin") + ^ +IndentationError: unindent does not match any outer indentation level +``` + +La dernière ligne a 2 espaces : elle n'est alignée ni avec le bloc (4 espaces) ni avec le `if` (0 espace). + +**Correction :** chaque ligne doit être alignée exactement sur un niveau existant. Dans VS Code, le nombre d'espaces de la ligne courante est indiqué en bas à droite, et le menu *Affichage* → *Afficher les espaces* (*Render Whitespace*) les rend visibles. + +### Piège 4 — `=` au lieu de `==` + +```python +age = 20 +if age = 18: + print("Pile 18 ans") +``` + +```text + File "conditions.py", line 2 + if age = 18: + ^^^^^^^^ +SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='? +``` + +**Correction :** `if age == 18:`. Python te le suggère d'ailleurs dans le message. + +### Piège 5 — Comparer une saisie non convertie + +```python +age = input("Âge : ") +if age >= 18: + print("Majeur") +``` + +```text +Âge : 20 +Traceback (most recent call last): + File "conditions.py", line 2, in + if age >= 18: + ^^^^^^^^^ +TypeError: '>=' not supported between instances of 'str' and 'int' +``` + +Python refuse de comparer le texte `"20"` au nombre `18`. Et avec `==`, c'est encore plus sournois : `"18" == 18` ne provoque pas d'erreur, mais vaut toujours `False` ! + +**Correction :** `age = int(input("Âge : "))`. + +### Piège 6 — Le faux ami `or` + +```python +couleur = "vert" +if couleur == "rouge" or "bleu": + print(f"{couleur} est une couleur primaire") +``` + +Sortie : + +```text +vert est une couleur primaire +``` + +Aucune erreur… mais un résultat faux ! Python lit `(couleur == "rouge") or ("bleu")`. Or `"bleu"` est une chaîne non vide, donc considérée comme **vraie** : la condition est toujours vraie. + +**Correction :** répète la comparaison, ou utilise `in` : + +```python +couleur = "vert" +if couleur == "rouge" or couleur == "bleu" or couleur == "jaune": + print(f"{couleur} est une couleur primaire") +else: + print(f"{couleur} n'est pas une couleur primaire") +``` + +Sortie : + +```text +vert n'est pas une couleur primaire +``` + +(Au chapitre 04, tu pourras écrire encore plus simplement `if couleur in ("rouge", "bleu", "jaune"):`.) + +### Piège 7 — Un `else` orphelin + +```python +x = 3 +if x > 0: + print("positif") +print("test terminé") +else: + print("négatif ou nul") +``` + +```text + File "conditions.py", line 5 + else: + ^^^^ +SyntaxError: invalid syntax +``` + +Le `print` non indenté a « fermé » le `if` : le `else` n'a plus de `if` auquel se rattacher. Même erreur si un `elif` apparaît sans `if` avant lui. + +**Correction :** le `else` doit suivre **immédiatement** le bloc du `if` (ou du dernier `elif`), au même niveau d'indentation que le `if`. + +### Piège 8 — Des conditions dans le mauvais ordre + +Comme vu à la section 5, avec `elif`, la **première** condition vraie gagne : + +```python +vitesse = 150 +if vitesse > 90: + print("Amende légère") +elif vitesse > 130: + print("Retrait de permis") +``` + +Sortie : + +```text +Amende légère +``` + +**Correction :** teste d'abord le cas le plus restrictif (`vitesse > 130`), puis `vitesse > 90`. + +--- + +## Exercices + +### Exercice 1 ⭐ — Positif, négatif ou nul + +Écris un programme qui demande un nombre (à virgule possible) et indique s'il est positif, négatif ou nul. + +Exemple d'exécution : + +```text +Un nombre : -4.5 +-4.5 est négatif. +``` + +### Exercice 2 ⭐ — Le plus grand de trois + +Écris un programme qui demande trois nombres entiers et affiche le plus grand, **sans utiliser** la fonction `max()`. + +Exemple d'exécution : + +```text +Premier nombre : 12 +Deuxième nombre : 45 +Troisième nombre : 7 +Le plus grand est 45. +``` + +### Exercice 3 ⭐ — Tarif du cinéma + +Un cinéma pratique les tarifs suivants : moins de 14 ans : 5 € ; de 14 à 25 ans inclus : 7.5 € ; de 26 à 64 ans : 11 € ; 65 ans et plus : 8 €. Écris un programme qui demande l'âge et affiche le tarif avec 2 décimales. + +Exemple d'exécution : + +```text +Âge : 25 +Tarif : 7.50 € +``` + +### Exercice 4 ⭐⭐ — Année bissextile + +Une année est **bissextile** si elle est divisible par 4 mais pas par 100, **ou** si elle est divisible par 400. Écris un programme qui demande une année et indique si elle est bissextile, en une seule condition combinant `and`, `or` et `%`. + +Exemples d'exécution : + +```text +Année : 2024 +2024 est bissextile. +``` + +```text +Année : 1900 +1900 n'est pas bissextile. +``` + +(2000 est bissextile, 2023 ne l'est pas.) + +### Exercice 5 ⭐⭐ — Calcul de l'IMC + +L'indice de masse corporelle (IMC) se calcule ainsi : IMC = poids / taille², avec le poids en kg et la taille en mètres. Écris un programme qui demande le poids et la taille, affiche l'IMC avec une décimale, puis l'interprétation : + +| IMC | Interprétation | +|---|---| +| moins de 18.5 | insuffisance pondérale | +| de 18.5 à moins de 25 | corpulence normale | +| de 25 à moins de 30 | surpoids | +| 30 et plus | obésité | + +Le programme doit refuser une taille négative ou nulle avec un message d'erreur. + +Exemple d'exécution : + +```text +Poids (kg) : 70 +Taille (m) : 1.75 +IMC : 22.9 (corpulence normale) +``` + +### Exercice 6 ⭐⭐ — Le jour de la semaine + +Écris un programme qui demande un numéro de jour entre 1 et 7 et affiche le nom du jour correspondant (1 = lundi) ainsi que « (week-end) » pour le samedi et le dimanche. Utilise `match`/`case`. Un numéro invalide affiche un message d'erreur. + +Exemples d'exécution : + +```text +Numéro du jour (1-7) : 6 +samedi (week-end) +``` + +```text +Numéro du jour (1-7) : 9 +Numéro invalide : 9 +``` + +### Exercice 7 ⭐⭐⭐ — Le type de triangle + +Écris un programme qui demande les longueurs des trois côtés d'un triangle (des entiers) et affiche : + +- `Ce n'est pas un triangle.` si une longueur est inférieure ou égale à 0, ou si un côté est plus grand ou égal à la somme des deux autres ; +- sinon, `Triangle équilatéral.`, `Triangle isocèle.` ou `Triangle quelconque.` ; +- et, en plus, ` Il est rectangle.` sur la même ligne si le triangle est rectangle (théorème de Pythagore : le carré du plus grand côté est égal à la somme des carrés des deux autres). + +Exemples d'exécution : + +```text +Côté a : 3 +Côté b : 4 +Côté c : 5 +Triangle quelconque. Il est rectangle. +``` + +```text +Côté a : 2 +Côté b : 2 +Côté c : 9 +Ce n'est pas un triangle. +``` + +### Exercice 8 ⭐⭐⭐ — Le distributeur de boissons + +Un distributeur propose : `1` café (1.20 €), `2` thé (1.00 €), `3` chocolat (1.50 €). Écris un programme qui : + +1. affiche le menu et demande le numéro de la boisson ; +2. si le choix est invalide, affiche « Choix invalide. » et s'arrête là ; +3. sinon, demande le montant inséré et : + - si le montant est insuffisant, indique combien il manque ; + - sinon, sert la boisson et indique la monnaie rendue (ou « Compte juste, merci ! » si elle vaut 0). + +Pour éviter les soucis d'imprécision des floats, fais tous les calculs **en centimes** (entiers) : `round(montant * 100)`. + +Exemple d'exécution : + +```text +1. Café (1.20 €) +2. Thé (1.00 €) +3. Chocolat (1.50 €) +Ton choix : 3 +Montant inséré (€) : 2 +Voici ton chocolat. Monnaie rendue : 0.50 € +``` + +--- + +## Mini-projet + +### Le quiz de culture générale + +**Objectif :** écrire un programme `quiz.py` qui pose 5 questions à l'utilisateur, compte les points et affiche une appréciation finale. + +**Cahier des charges :** + +1. Le programme affiche un titre et demande le prénom du joueur. Si le prénom est vide, il utilise « Joueur mystère ». +2. Il pose **5 questions** de types variés : + - au moins une question à réponse **texte**, tolérante aux majuscules et aux espaces (`strip().lower()`), et qui accepte plusieurs variantes de réponse (par exemple « paris » ou « la ville de paris ») ; + - au moins une question à réponse **numérique** entière ; + - au moins une question à réponse numérique où une réponse **approchée** rapporte la moitié des points (par exemple, à 5 ans près pour une date) ; + - au moins une question **à choix multiples** (a, b, c, d) traitée avec `match`/`case`, qui affiche un message spécifique pour chaque mauvaise réponse ; + - au moins une question **vrai/faux**. +3. Après chaque réponse, le programme affiche « Bonne réponse ! » ou la bonne réponse. +4. Le score (qui peut contenir des demi-points) est affiché à la fin sur 5, avec une appréciation selon le score : 5 → « Parfait ! », 4 ou plus → « Excellent », 2.5 ou plus → « Pas mal », moins → « Révise un peu ». +5. Le score est affiché sans décimale inutile : `4` plutôt que `4.0`, mais `3.5` reste `3.5` (indice : le format `:g`). + +**Exemple d'exécution :** + +```text +===== QUIZ DE CULTURE GÉNÉRALE ===== +Ton prénom : Léa + +Question 1 : Quelle est la capitale de la France ? +> Paris +Bonne réponse ! + +Question 2 : Combien de pattes a une araignée ? +> 8 +Bonne réponse ! + +Question 3 : En quelle année l'homme a-t-il marché sur la Lune ? +> 1970 +Presque ! C'était en 1969. (+0.5) + +Question 4 : Quel est le plus grand océan ? + a) Atlantique b) Indien c) Pacifique d) Arctique +> a +Non, l'Atlantique est le deuxième. C'était le Pacifique. + +Question 5 : Vrai ou faux : la tomate est un fruit ? +> vrai +Bonne réponse ! + +Léa, ton score : 3.5 / 5 — Pas mal +``` + +**Étapes suggérées :** + +1. Écris l'en-tête, la saisie du prénom (avec la valeur par défaut) et initialise `score = 0`. +2. Écris la question 1 et teste-la avec plusieurs variantes de réponse (`PARIS`, ` paris `, `Lyon`). +3. Ajoute les questions une par une, en testant à chaque fois les bonnes et les mauvaises réponses. +4. Termine par l'appréciation finale avec une chaîne `if`/`elif`/`else`. + +**Pistes d'amélioration :** + +- Ajoute un « mode difficile » choisi au début, qui change les questions posées. +- Pour la question numérique, vérifie que la saisie contient bien des chiffres avec `reponse.isdigit()` avant de convertir, pour éviter le plantage (une approche plus robuste avec `try`/`except` sera vue au chapitre 08). +- Au chapitre 03, tu pourras proposer de rejouer, ou donner plusieurs essais par question ; au chapitre 04, stocker les questions dans une liste. + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +```python +# Positif, négatif ou nul +nombre = float(input("Un nombre : ")) + +if nombre > 0: + print(f"{nombre} est positif.") +elif nombre < 0: + print(f"{nombre} est négatif.") +else: + print(f"{nombre} est nul.") +``` + +Exemple d'exécution : + +```text +Un nombre : -4.5 +-4.5 est négatif. +``` + +Le `else` n'a pas besoin de condition : si le nombre n'est ni positif ni négatif, il est forcément nul. + +
+ +
+Corrigé de l'exercice 2 + +```python +# Le plus grand de trois nombres, sans max() +a = int(input("Premier nombre : ")) +b = int(input("Deuxième nombre : ")) +c = int(input("Troisième nombre : ")) + +if a >= b and a >= c: + plus_grand = a +elif b >= c: # ici, on sait déjà que a n'est pas le plus grand + plus_grand = b +else: + plus_grand = c + +print(f"Le plus grand est {plus_grand}.") +``` + +Exemple d'exécution : + +```text +Premier nombre : 12 +Deuxième nombre : 45 +Troisième nombre : 7 +Le plus grand est 45. +``` + +Autre approche, très utile pour la suite : supposer que le premier est le plus grand, puis corriger si besoin. + +```python +a, b, c = 12, 45, 7 +plus_grand = a +if b > plus_grand: + plus_grand = b +if c > plus_grand: + plus_grand = c +print(f"Le plus grand est {plus_grand}.") +``` + +Sortie : + +```text +Le plus grand est 45. +``` + +Ici, on utilise des `if` **indépendants** (pas de `elif`) : chaque nombre doit être comparé. Cette technique se généralisera très bien avec les boucles au chapitre 03. + +
+ +
+Corrigé de l'exercice 3 + +```python +# Tarif du cinéma selon l'âge +age = int(input("Âge : ")) + +if age < 14: + tarif = 5 +elif age <= 25: + tarif = 7.5 +elif age < 65: + tarif = 11 +else: + tarif = 8 + +print(f"Tarif : {tarif:.2f} €") +``` + +Exemple d'exécution : + +```text +Âge : 25 +Tarif : 7.50 € +``` + +Grâce à `elif`, inutile d'écrire `14 <= age <= 25` : si on arrive au deuxième test, on sait déjà que `age >= 14`. Attention aux bornes : « de 14 à 25 ans inclus » se traduit par `<= 25`. + +
+ +
+Corrigé de l'exercice 4 + +```python +# Année bissextile +annee = int(input("Année : ")) + +if (annee % 4 == 0 and annee % 100 != 0) or annee % 400 == 0: + print(f"{annee} est bissextile.") +else: + print(f"{annee} n'est pas bissextile.") +``` + +Exemples d'exécution : + +```text +Année : 2024 +2024 est bissextile. +``` + +```text +Année : 1900 +1900 n'est pas bissextile. +``` + +Les parenthèses ne sont pas strictement nécessaires ici (`and` est prioritaire sur `or`), mais elles rendent la règle bien plus lisible. Vérifie aussi : 2000 → divisible par 400 → bissextile ; 2023 → pas divisible par 4 → non. + +
+ +
+Corrigé de l'exercice 5 + +```python +# Calcul de l'IMC +poids = float(input("Poids (kg) : ")) +taille = float(input("Taille (m) : ")) + +if taille <= 0: + print("Erreur : la taille doit être strictement positive.") +else: + imc = poids / taille ** 2 + + if imc < 18.5: + interpretation = "insuffisance pondérale" + elif imc < 25: + interpretation = "corpulence normale" + elif imc < 30: + interpretation = "surpoids" + else: + interpretation = "obésité" + + print(f"IMC : {imc:.1f} ({interpretation})") +``` + +Exemple d'exécution : + +```text +Poids (kg) : 70 +Taille (m) : 1.75 +IMC : 22.9 (corpulence normale) +``` + +On vérifie la taille **avant** de calculer, sinon une taille de 0 provoquerait une `ZeroDivisionError`. Tout le calcul est dans le bloc `else`, d'où le deuxième niveau d'indentation. `taille ** 2` est calculé avant la division, car `**` est prioritaire. + +
+ +
+Corrigé de l'exercice 6 + +```python +# Nom du jour avec match/case +numero = int(input("Numéro du jour (1-7) : ")) + +match numero: + case 1: + print("lundi") + case 2: + print("mardi") + case 3: + print("mercredi") + case 4: + print("jeudi") + case 5: + print("vendredi") + case 6: + print("samedi (week-end)") + case 7: + print("dimanche (week-end)") + case _: + print(f"Numéro invalide : {numero}") +``` + +Exemples d'exécution : + +```text +Numéro du jour (1-7) : 6 +samedi (week-end) +``` + +```text +Numéro du jour (1-7) : 9 +Numéro invalide : 9 +``` + +On convertit avec `int()`, car `case 6:` compare à l'**entier** 6 : la chaîne `"6"` ne correspondrait à aucun cas. Au chapitre 04, une liste des noms de jours permettra d'écrire ce programme en trois lignes. + +
+ +
+Corrigé de l'exercice 7 + +```python +# Type de triangle +a = int(input("Côté a : ")) +b = int(input("Côté b : ")) +c = int(input("Côté c : ")) + +if a <= 0 or b <= 0 or c <= 0 or a >= b + c or b >= a + c or c >= a + b: + print("Ce n'est pas un triangle.") +else: + # Nature du triangle + if a == b == c: + message = "Triangle équilatéral." + elif a == b or b == c or a == c: + message = "Triangle isocèle." + else: + message = "Triangle quelconque." + + # Est-il rectangle ? On cherche le plus grand côté. + if a >= b and a >= c: + rectangle = a ** 2 == b ** 2 + c ** 2 + elif b >= c: + rectangle = b ** 2 == a ** 2 + c ** 2 + else: + rectangle = c ** 2 == a ** 2 + b ** 2 + + if rectangle: + message = message + " Il est rectangle." + + print(message) +``` + +Exemples d'exécution : + +```text +Côté a : 3 +Côté b : 4 +Côté c : 5 +Triangle quelconque. Il est rectangle. +``` + +```text +Côté a : 2 +Côté b : 2 +Côté c : 9 +Ce n'est pas un triangle. +``` + +Points importants : + +- l'équilatéral est testé **avant** l'isocèle (un triangle équilatéral a aussi deux côtés égaux) ; +- `a == b == c` est une comparaison enchaînée ; +- `rectangle = a ** 2 == b ** 2 + c ** 2` stocke directement le **booléen** résultant de la comparaison, sans `if` ; +- comme les côtés sont des entiers, la comparaison avec `==` est exacte (pas de souci de floats). + +
+ +
+Corrigé de l'exercice 8 + +```python +# Distributeur de boissons (calculs en centimes) +print("1. Café (1.20 €)") +print("2. Thé (1.00 €)") +print("3. Chocolat (1.50 €)") +choix = input("Ton choix : ").strip() + +match choix: + case "1": + boisson, prix_centimes = "café", 120 + case "2": + boisson, prix_centimes = "thé", 100 + case "3": + boisson, prix_centimes = "chocolat", 150 + case _: + boisson, prix_centimes = "", 0 + +if boisson == "": + print("Choix invalide.") +else: + montant = float(input("Montant inséré (€) : ")) + montant_centimes = round(montant * 100) + + if montant_centimes < prix_centimes: + manque = prix_centimes - montant_centimes + print(f"Montant insuffisant : il manque {manque / 100:.2f} €.") + else: + rendu = montant_centimes - prix_centimes + if rendu == 0: + print(f"Voici ton {boisson}. Compte juste, merci !") + else: + print(f"Voici ton {boisson}. Monnaie rendue : {rendu / 100:.2f} €") +``` + +Exemple d'exécution : + +```text +1. Café (1.20 €) +2. Thé (1.00 €) +3. Chocolat (1.50 €) +Ton choix : 3 +Montant inséré (€) : 2 +Voici ton chocolat. Monnaie rendue : 0.50 € +``` + +Autre exécution : + +```text +1. Café (1.20 €) +2. Thé (1.00 €) +3. Chocolat (1.50 €) +Ton choix : 1 +Montant inséré (€) : 1 +Montant insuffisant : il manque 0.20 €. +``` + +On garde le choix sous forme de **texte** : ainsi, une saisie comme `abc` tombe simplement dans le `case _` au lieu de faire planter `int()`. Travailler en centimes évite les surprises : `1.5 - 1.2` donne `0.30000000000000004`, alors que `150 - 120` donne exactement `30`. + +
+ +
+Corrigé du mini-projet + +```python +# quiz.py — Quiz de culture générale en 5 questions +print("===== QUIZ DE CULTURE GÉNÉRALE =====") +prenom = input("Ton prénom : ").strip() +if not prenom: + prenom = "Joueur mystère" + +score = 0 + +# --- Question 1 : réponse texte, plusieurs variantes acceptées --- +print() +print("Question 1 : Quelle est la capitale de la France ?") +reponse = input("> ").strip().lower() +if reponse == "paris" or reponse == "la ville de paris": + print("Bonne réponse !") + score += 1 +else: + print("Raté, c'était Paris.") + +# --- Question 2 : réponse numérique exacte --- +print() +print("Question 2 : Combien de pattes a une araignée ?") +reponse = int(input("> ")) +if reponse == 8: + print("Bonne réponse !") + score += 1 +else: + print("Raté, une araignée a 8 pattes.") + +# --- Question 3 : réponse numérique approchée --- +print() +print("Question 3 : En quelle année l'homme a-t-il marché sur la Lune ?") +reponse = int(input("> ")) +if reponse == 1969: + print("Bonne réponse !") + score += 1 +elif abs(reponse - 1969) <= 5: + print("Presque ! C'était en 1969. (+0.5)") + score += 0.5 +else: + print("Raté, c'était en 1969.") + +# --- Question 4 : choix multiples avec match --- +print() +print("Question 4 : Quel est le plus grand océan ?") +print(" a) Atlantique b) Indien c) Pacifique d) Arctique") +reponse = input("> ").strip().lower() +match reponse: + case "c": + print("Bonne réponse !") + score += 1 + case "a": + print("Non, l'Atlantique est le deuxième. C'était le Pacifique.") + case "b": + print("Non, l'océan Indien est le troisième. C'était le Pacifique.") + case "d": + print("Non, l'Arctique est le plus petit ! C'était le Pacifique.") + case _: + print("Réponse invalide. C'était le Pacifique.") + +# --- Question 5 : vrai / faux --- +print() +print("Question 5 : Vrai ou faux : la tomate est un fruit ?") +reponse = input("> ").strip().lower() +if reponse == "vrai" or reponse == "v": + print("Bonne réponse !") + score += 1 +else: + print("Raté : botaniquement, la tomate est bien un fruit.") + +# --- Résultat final --- +if score == 5: + appreciation = "Parfait !" +elif score >= 4: + appreciation = "Excellent" +elif score >= 2.5: + appreciation = "Pas mal" +else: + appreciation = "Révise un peu" + +print() +print(f"{prenom}, ton score : {score:g} / 5 — {appreciation}") +``` + +Exemple d'exécution (mêmes réponses que dans l'énoncé) : + +```text +===== QUIZ DE CULTURE GÉNÉRALE ===== +Ton prénom : Léa + +Question 1 : Quelle est la capitale de la France ? +> Paris +Bonne réponse ! + +Question 2 : Combien de pattes a une araignée ? +> 8 +Bonne réponse ! + +Question 3 : En quelle année l'homme a-t-il marché sur la Lune ? +> 1970 +Presque ! C'était en 1969. (+0.5) + +Question 4 : Quel est le plus grand océan ? + a) Atlantique b) Indien c) Pacifique d) Arctique +> a +Non, l'Atlantique est le deuxième. C'était le Pacifique. + +Question 5 : Vrai ou faux : la tomate est un fruit ? +> vrai +Bonne réponse ! + +Léa, ton score : 3.5 / 5 — Pas mal +``` + +Remarques : + +- `if not prenom:` détecte une chaîne vide (section 8 du cours). +- `abs(reponse - 1969) <= 5` signifie « à 5 ans près » : `abs()` donne l'écart sans se soucier du signe (1964 et 1974 sont acceptés). +- Le `elif` de la question 3 n'est testé que si la réponse n'est pas exacte : une réponse exacte ne rapporte donc pas 1.5 point. +- `{score:g}` affiche `4` pour `4` ou `4.0`, et `3.5` pour `3.5`. +- On remarque beaucoup de répétitions d'une question à l'autre : les boucles (chapitre 03), les listes (chapitre 04) et les fonctions (chapitre 05) permettront de les éliminer. + +
+ +--- + +## Récapitulatif + +**Points clés :** + +- Les opérateurs de comparaison `==`, `!=`, `<`, `>`, `<=`, `>=` renvoient un booléen. Ne confonds pas `=` (affectation) et `==` (comparaison). +- Structure : `if condition:` puis un bloc **indenté de 4 espaces**. `elif` et `else` sont facultatifs ; `else` n'a jamais de condition. +- Dans une chaîne `if`/`elif`/`else`, **seul le premier bloc dont la condition est vraie** s'exécute : l'ordre des conditions compte. +- `and` (toutes vraies), `or` (au moins une vraie), `not` (inverse). Priorité : `not` > `and` > `or` ; mets des parenthèses pour être clair. +- `a <= x <= b` permet d'écrire un encadrement. +- `0`, `0.0`, `""` sont considérés comme faux ; `if texte:` signifie « si le texte n'est pas vide ». +- `in` teste si un morceau de texte est contenu dans une chaîne. +- `valeur1 if condition else valeur2` choisit entre deux valeurs sur une ligne. +- `match valeur:` / `case ...:` / `case _:` compare une valeur à plusieurs cas ; `|` regroupe plusieurs valeurs, `case x if ...:` ajoute une garde. +- Convertis toujours les saisies numériques avant de les comparer, et normalise les saisies texte avec `.strip().lower()`. + +**Checklist de validation :** + +- [ ] Je sais écrire une condition avec `if`, `elif` et `else`, avec les `:` et l'indentation correcte. +- [ ] Je sais prédire quel bloc s'exécute dans une chaîne de `elif`, et j'ordonne mes conditions en conséquence. +- [ ] Je sais combiner des conditions avec `and`, `or`, `not` et je connais le piège `x == "a" or "b"`. +- [ ] Je sais utiliser une comparaison enchaînée et l'expression `a if condition else b`. +- [ ] Je sais écrire un `match`/`case` avec un cas par défaut `_`. +- [ ] Mes programmes ne plantent plus sur une division par zéro grâce à une vérification préalable. +- [ ] J'ai réalisé le quiz et testé chaque question avec une bonne et une mauvaise réponse. + +--- + +[← Chapitre précédent](01-variables-types-operateurs.md) | [Sommaire](README.md) | [Chapitre suivant →](03-boucles.md) diff --git a/docs/presentations/Python/03-boucles.md b/docs/presentations/Python/03-boucles.md new file mode 100644 index 0000000..4cccae3 --- /dev/null +++ b/docs/presentations/Python/03-boucles.md @@ -0,0 +1,1688 @@ +# Chapitre 03 — Les boucles : répéter sans se répéter + +> **⏱️ Durée estimée :** 5 heures +> +> **📋 Prérequis :** [Chapitre 02 — Les conditions](02-conditions.md) : `if`/`elif`/`else`, opérateurs de comparaison et logiques, indentation des blocs. +> +> **🎯 Objectifs — À la fin de ce chapitre, tu sauras :** +> - répéter des instructions un nombre connu de fois avec `for` et `range()` ; +> - parcourir les caractères d'une chaîne et les éléments d'une liste simple ; +> - répéter tant qu'une condition est vraie avec `while`, et éviter les boucles infinies ; +> - contrôler une boucle avec `break` et `continue`, et utiliser le `else` de boucle ; +> - utiliser les motifs classiques : compteur, accumulateur, recherche du maximum, saisie validée ; +> - imbriquer des boucles pour produire des tableaux et des motifs. + +--- + +## Cours + +### 1. Pourquoi des boucles ? + +Imagine qu'on te demande d'afficher les nombres de 1 à 5. Facile : + +```python +print(1) +print(2) +print(3) +print(4) +print(5) +``` + +Maintenant, de 1 à 1000. Tu ne vas pas écrire 1000 lignes ! Et si le nombre est choisi par l'utilisateur, c'est même impossible à écrire à l'avance. + +Une **boucle** permet de répéter un bloc d'instructions plusieurs fois. C'est l'une des forces des ordinateurs : ils ne s'ennuient jamais et ne se trompent pas en répétant. + +```python +for nombre in range(1, 6): + print(nombre) +``` + +Sortie : + +```text +1 +2 +3 +4 +5 +``` + +Deux lignes, et pour aller jusqu'à 1000, il suffit de changer `6` en `1001`. + +Python propose deux sortes de boucles : + +- **`for`** : « pour chaque élément de… ». On l'utilise quand on sait **sur quoi** on boucle (une plage de nombres, les lettres d'un mot, les éléments d'une liste). +- **`while`** : « tant que… ». On l'utilise quand on ne sait pas à l'avance combien de tours il faudra (attendre une bonne réponse, jouer jusqu'à la victoire). + +### 2. La boucle `for` avec `range()` + +#### 2.1. Structure + +```python +for i in range(3): + print("Bonjour !") +``` + +Sortie : + +```text +Bonjour ! +Bonjour ! +Bonjour ! +``` + +La structure ressemble à celle de `if` : une ligne qui se termine par `:`, puis un **bloc indenté** qui sera répété. Chaque répétition s'appelle un **tour** (ou une **itération**). + +À chaque tour, la **variable de boucle** (ici `i`) prend une nouvelle valeur. On peut s'en servir dans le bloc : + +```python +for i in range(3): + print(f"Tour numéro {i}") +print("Fini !") +``` + +Sortie : + +```text +Tour numéro 0 +Tour numéro 1 +Tour numéro 2 +Fini ! +``` + +Remarques importantes : + +- `range(3)` produit les nombres **0, 1, 2** : il commence à **0** et s'arrête **avant** 3. Il y a bien 3 tours. +- Le `print("Fini !")`, non indenté, est exécuté **une seule fois**, après la fin de la boucle. + +#### 2.2. Les trois formes de `range()` + +| Écriture | Nombres produits | Explication | +|---|---|---| +| `range(fin)` | 0, 1, …, fin − 1 | de 0 jusqu'à `fin` exclu | +| `range(debut, fin)` | debut, …, fin − 1 | de `debut` inclus à `fin` exclu | +| `range(debut, fin, pas)` | debut, debut + pas, … | en avançant de `pas` à chaque fois | + +```python +print("range(5) :", end=" ") +for i in range(5): + print(i, end=" ") +print() + +print("range(2, 6) :", end=" ") +for i in range(2, 6): + print(i, end=" ") +print() + +print("range(0, 20, 5) :", end=" ") +for i in range(0, 20, 5): + print(i, end=" ") +print() + +print("range(5, 0, -1) :", end=" ") +for i in range(5, 0, -1): + print(i, end=" ") +print() +``` + +Sortie : + +```text +range(5) : 0 1 2 3 4 +range(2, 6) : 2 3 4 5 +range(0, 20, 5) : 0 5 10 15 +range(5, 0, -1) : 5 4 3 2 1 +``` + +On utilise ici `end=" "` (vu au chapitre 01) pour afficher les nombres sur une même ligne, puis un `print()` vide pour passer à la ligne. + +> 💡 **Pourquoi la fin est-elle exclue ?** C'est une convention qui simplifie beaucoup de choses : `range(n)` fait exactement `n` tours, et `range(a, b)` fait `b - a` tours. Pour aller **jusqu'à** `n` inclus, écris `range(1, n + 1)`. + +Avec un pas négatif, on compte à rebours. Un compte à rebours de fusée : + +```python +for seconde in range(5, 0, -1): + print(f"{seconde}...") +print("Décollage ! 🚀") +``` + +Sortie : + +```text +5... +4... +3... +2... +1... +Décollage ! 🚀 +``` + +`range()` n'accepte que des **entiers** : `range(3.5)` provoque une erreur (voir les pièges). + +#### 2.3. La variable de boucle + +Le nom de la variable de boucle est libre. On utilise souvent `i` (pour *indice*) quand c'est un simple compteur, mais un nom parlant est préférable : + +```python +for annee in range(2020, 2026): + print(f"Bonne année {annee} !") +``` + +Sortie : + +```text +Bonne année 2020 ! +Bonne année 2021 ! +Bonne année 2022 ! +Bonne année 2023 ! +Bonne année 2024 ! +Bonne année 2025 ! +``` + +Si tu n'as pas besoin de la variable, la convention est de l'appeler `_` : + +```python +for _ in range(3): + print("Hip hip hip, hourra !") +``` + +Sortie : + +```text +Hip hip hip, hourra ! +Hip hip hip, hourra ! +Hip hip hip, hourra ! +``` + +#### 2.4. Le nombre de tours peut venir de l'utilisateur + +```python +n = int(input("Jusqu'à combien veux-tu compter ? ")) +for nombre in range(1, n + 1): + print(nombre, end=" ") +print() +``` + +Exemple d'exécution : + +```text +Jusqu'à combien veux-tu compter ? 7 +1 2 3 4 5 6 7 +``` + +### 3. Parcourir une chaîne et une liste simple + +`for` ne fonctionne pas qu'avec `range()`. Il sait parcourir **tout ce qui contient plusieurs éléments**. On dit que ces objets sont **itérables**. + +#### 3.1. Les caractères d'une chaîne + +```python +mot = "Python" +for lettre in mot: + print(lettre) +``` + +Sortie : + +```text +P +y +t +h +o +n +``` + +À chaque tour, `lettre` contient le caractère suivant. Pratique pour analyser un texte : + +```python +phrase = "Le chat dort sur le canapé" +nb_espaces = 0 +for caractere in phrase: + if caractere == " ": + nb_espaces += 1 +print(f"Il y a {nb_espaces} espaces, donc {nb_espaces + 1} mots.") +``` + +Sortie : + +```text +Il y a 5 espaces, donc 6 mots. +``` + +Remarque comment on combine une boucle et une condition : le `if` est **dans** la boucle, il a donc 4 espaces d'indentation de plus. + +#### 3.2. Un aperçu des listes + +Une **liste** est une suite de valeurs, écrite entre crochets `[ ]` et séparée par des virgules. Les listes ont de très nombreuses possibilités, qui seront détaillées au [chapitre 04](04-structures-de-donnees.md). Pour l'instant, retiens simplement qu'on peut les parcourir avec `for` : + +```python +fruits = ["pomme", "banane", "cerise"] +for fruit in fruits: + print(f"J'aime la {fruit}.") +``` + +Sortie : + +```text +J'aime la pomme. +J'aime la banane. +J'aime la cerise. +``` + +```python +notes = [12, 15.5, 9, 17] +for note in notes: + if note >= 10: + print(f"{note} : admis") + else: + print(f"{note} : non admis") +``` + +Sortie : + +```text +12 : admis +15.5 : admis +9 : non admis +17 : admis +``` + +> 💡 **Convention de nommage :** une liste a un nom au pluriel (`fruits`, `notes`), la variable de boucle le même nom au singulier (`fruit`, `note`). Le code se lit alors presque comme du français : « pour chaque fruit dans fruits ». + +### 4. Les motifs classiques avec `for` + +Certaines façons d'utiliser les boucles reviennent sans cesse. Les connaître, c'est avoir une boîte à outils toute prête. + +#### 4.1. L'accumulateur : faire une somme + +Pour additionner une série de nombres, on part d'une variable à **0**, et on lui ajoute chaque nombre à chaque tour : + +```python +total = 0 +for nombre in range(1, 101): + total += nombre +print(f"La somme des nombres de 1 à 100 vaut {total}.") +``` + +Sortie : + +```text +La somme des nombres de 1 à 100 vaut 5050. +``` + +Pense à une tirelire : elle est vide au départ (`total = 0`), et à chaque tour on y glisse une pièce (`total += nombre`). + +**Suivre l'exécution à la main** est un excellent exercice. Avec `range(1, 5)` : + +| Tour | `nombre` | `total` avant | `total` après | +|---|---|---|---| +| 1 | 1 | 0 | 1 | +| 2 | 2 | 1 | 3 | +| 3 | 3 | 3 | 6 | +| 4 | 4 | 6 | 10 | + +> ⚠️ L'initialisation `total = 0` doit être **avant** la boucle. Si tu la mets dedans, le total est remis à zéro à chaque tour ! + +Le même principe fonctionne pour un **produit** (en partant de **1**, pas de 0) : + +```python +n = 5 +factorielle = 1 +for i in range(1, n + 1): + factorielle *= i +print(f"{n}! = {factorielle}") +``` + +Sortie : + +```text +5! = 120 +``` + +Et même pour construire une **chaîne**, en partant d'une chaîne vide : + +```python +mot = "bonjour" +a_l_envers = "" +for lettre in mot: + a_l_envers = lettre + a_l_envers # on ajoute chaque lettre AU DÉBUT +print(a_l_envers) +``` + +Sortie : + +```text +ruojnob +``` + +#### 4.2. Le compteur : compter ce qui vérifie une condition + +On part de 0, et on ajoute 1 seulement quand la condition est vraie : + +```python +texte = "Anticonstitutionnellement" +nb_voyelles = 0 +for lettre in texte.lower(): + if lettre in "aeiouy": + nb_voyelles += 1 +print(f"{texte} contient {nb_voyelles} voyelles.") +``` + +Sortie : + +```text +Anticonstitutionnellement contient 10 voyelles. +``` + +`texte.lower()` (vu au chapitre 02) met tout en minuscules, pour que le `A` majuscule soit aussi compté. + +#### 4.3. La saisie répétée : calculer une moyenne + +```python +nb_notes = int(input("Combien de notes ? ")) +somme = 0 +for i in range(1, nb_notes + 1): + note = float(input(f"Note {i} : ")) + somme += note +moyenne = somme / nb_notes +print(f"Moyenne : {moyenne:.2f}") +``` + +Exemple d'exécution : + +```text +Combien de notes ? 3 +Note 1 : 12 +Note 2 : 15.5 +Note 3 : 9 +Moyenne : 12.17 +``` + +#### 4.4. Chercher le maximum (ou le minimum) + +On garde en mémoire « le plus grand vu jusqu'ici », et on le met à jour quand on trouve mieux : + +```python +temperatures = [14, 19, 23, 17, 21] +plus_chaude = temperatures[0] # on suppose que la première est la plus grande +for temperature in temperatures: + if temperature > plus_chaude: + plus_chaude = temperature +print(f"Température maximale : {plus_chaude} °C") +``` + +Sortie : + +```text +Température maximale : 23 °C +``` + +`temperatures[0]` désigne le premier élément de la liste (les positions commencent à 0, comme `range`). Bien sûr, `max(temperatures)` ferait le même travail, mais comprendre ce motif est essentiel : tu en auras besoin pour des recherches plus complexes (« l'élève qui a la meilleure note », « le produit le moins cher en stock »…). + +### 5. La boucle `while` + +#### 5.1. Structure + +`while` signifie « tant que ». La boucle répète son bloc **tant que** la condition est vraie : + +```python +compteur = 1 +while compteur <= 5: + print(f"Compteur : {compteur}") + compteur += 1 +print("Terminé") +``` + +Sortie : + +```text +Compteur : 1 +Compteur : 2 +Compteur : 3 +Compteur : 4 +Compteur : 5 +Terminé +``` + +Le déroulement : + +1. Python évalue la condition `compteur <= 5`. +2. Si elle est **vraie**, il exécute le bloc, puis **revient à l'étape 1**. +3. Si elle est **fausse**, il saute le bloc et continue après la boucle. + +C'est comme un `if` qui se répète. Si la condition est fausse dès le départ, le bloc n'est **jamais** exécuté. + +> ⚠️ **Les trois ingrédients d'un `while` correct :** +> 1. une variable **initialisée avant** la boucle (`compteur = 1`) ; +> 2. une **condition** qui la teste (`compteur <= 5`) ; +> 3. une **modification** de cette variable dans le bloc (`compteur += 1`), pour que la condition finisse par devenir fausse. + +#### 5.2. La boucle infinie + +Si tu oublies le troisième ingrédient, la condition reste vraie pour toujours : c'est une **boucle infinie**. + +```python +compteur = 0 +while compteur < 5: + print("Tour", compteur) + # oubli de compteur += 1 ! +``` + +Le programme affiche `Tour 0` à l'infini. **Pour l'arrêter, appuie sur `Ctrl` + `C`** dans le terminal. Python s'interrompt alors avec ce message, qui n'a rien d'inquiétant : + +```text +Traceback (most recent call last): + File "boucle.py", line 3, in + print("Tour", compteur) + ~~~~~^^^^^^^^^^^^^^^^^^ +KeyboardInterrupt +``` + +Tout programmeur écrit des boucles infinies par accident de temps en temps. Pas de panique : `Ctrl` + `C`. + +#### 5.3. Quand utiliser `while` ? + +Toute boucle `for i in range(...)` peut s'écrire avec `while`, mais `for` est alors plus simple et plus sûr (pas de risque d'oublier l'incrémentation). Réserve `while` aux situations où **le nombre de tours n'est pas connu à l'avance**. + +Exemple : combien d'années faut-il pour doubler une épargne placée à 3 % par an ? + +```python +capital = 1000 +objectif = 2000 +annees = 0 +while capital < objectif: + capital *= 1.03 + annees += 1 +print(f"Il faut {annees} ans pour atteindre {capital:.2f} €.") +``` + +Sortie : + +```text +Il faut 24 ans pour atteindre 2032.79 €. +``` + +On ne pouvait pas savoir à l'avance qu'il faudrait 24 tours : c'est un cas idéal pour `while`. + +#### 5.4. Valider une saisie + +Un usage extrêmement courant : redemander une valeur **tant qu'elle n'est pas valide**. + +```python +note = float(input("Note sur 20 : ")) +while note < 0 or note > 20: + print("Erreur : la note doit être comprise entre 0 et 20.") + note = float(input("Note sur 20 : ")) +print(f"Note enregistrée : {note}") +``` + +Exemple d'exécution : + +```text +Note sur 20 : 25 +Erreur : la note doit être comprise entre 0 et 20. +Note sur 20 : -3 +Erreur : la note doit être comprise entre 0 et 20. +Note sur 20 : 14 +Note enregistrée : 14.0 +``` + +Ici, la boucle tourne 0, 1 ou 100 fois selon l'utilisateur : impossible à prévoir. + +(Si l'utilisateur tape des lettres, `float()` plante encore : la gestion de ce cas avec `try`/`except` sera vue au [chapitre 08](08-erreurs-et-debogage.md).) + +### 6. `break` et `continue` + +Ces deux mots-clés permettent de modifier le déroulement normal d'une boucle. Ils s'utilisent presque toujours à l'intérieur d'un `if`. + +#### 6.1. `break` : sortir immédiatement + +`break` **interrompt** la boucle sur-le-champ. Python passe directement à la suite du programme, sans terminer le tour en cours ni faire les suivants. + +```python +for nombre in range(1, 100): + if nombre * nombre > 50: + print(f"{nombre} est le premier nombre dont le carré dépasse 50.") + break + print(f"{nombre}² = {nombre * nombre}") +``` + +Sortie : + +```text +1² = 1 +2² = 4 +3² = 9 +4² = 16 +5² = 25 +6² = 36 +7² = 49 +8 est le premier nombre dont le carré dépasse 50. +``` + +#### 6.2. Le motif `while True` + `break` + +`while True:` crée une boucle volontairement infinie, dont on sort avec `break` quand on le décide. C'est très lisible pour les menus et les programmes qui tournent jusqu'à ce que l'utilisateur dise stop : + +```python +print("Tape des mots ('fin' pour arrêter).") +nb_mots = 0 +while True: + mot = input("> ").strip() + if mot == "fin": + break + nb_mots += 1 + print(f"'{mot}' contient {len(mot)} lettres.") +print(f"Tu as tapé {nb_mots} mots.") +``` + +Exemple d'exécution : + +```text +Tape des mots ('fin' pour arrêter). +> soleil +'soleil' contient 6 lettres. +> lune +'lune' contient 4 lettres. +> fin +Tu as tapé 2 mots. +``` + +Avec ce motif, la saisie de validation de la section 5.4 s'écrit sans dupliquer la ligne `input` : + +```python +while True: + note = float(input("Note sur 20 : ")) + if 0 <= note <= 20: + break + print("Erreur : la note doit être comprise entre 0 et 20.") +print(f"Note enregistrée : {note}") +``` + +Exemple d'exécution : + +```text +Note sur 20 : 25 +Erreur : la note doit être comprise entre 0 et 20. +Note sur 20 : 14 +Note enregistrée : 14.0 +``` + +#### 6.3. `continue` : passer au tour suivant + +`continue` **abandonne le tour en cours** et passe directement au tour suivant (la boucle, elle, continue). + +```python +for nombre in range(1, 11): + if nombre % 3 == 0: + continue # on saute les multiples de 3 + print(nombre, end=" ") +print() +``` + +Sortie : + +```text +1 2 4 5 7 8 10 +``` + +Analogie : tu tries ton courrier. `continue`, c'est « cette lettre est une pub, je la jette et je passe à la suivante ». `break`, c'est « j'ai trouvé la lettre que je cherchais, j'arrête de trier ». + +`continue` est utile pour **ignorer les cas sans intérêt** dès le début du bloc, plutôt que d'imbriquer tout le reste dans un `if` : + +```python +phrase = "Il fait beau, allons-y !" +lettres = "" +for caractere in phrase: + if caractere in " ,!-": + continue + lettres += caractere +print(lettres) +``` + +Sortie : + +```text +Ilfaitbeauallonsy +``` + +### 7. Le `else` de boucle + +Voici une particularité de Python peu connue mais très pratique : une boucle `for` ou `while` peut avoir un bloc `else`. Il s'exécute **si la boucle s'est terminée normalement**, c'est-à-dire **sans avoir rencontré de `break`**. + +C'est idéal pour les **recherches** : « j'ai cherché partout et je n'ai rien trouvé ». + +```python +mot = "lynx" +for lettre in mot: + if lettre in "aeiou": + print(f"Première voyelle trouvée : {lettre}") + break +else: + print(f"Aucune voyelle (hors y) dans « {mot} ».") +``` + +Sortie : + +```text +Aucune voyelle (hors y) dans « lynx ». +``` + +Avec `mot = "python"`, le `break` aurait été déclenché sur le `o`, et le `else` n'aurait pas été exécuté. + +L'exemple classique : tester si un nombre est **premier** (divisible seulement par 1 et par lui-même). + +```python +nombre = 97 +for diviseur in range(2, nombre): + if nombre % diviseur == 0: + print(f"{nombre} n'est pas premier : il est divisible par {diviseur}.") + break +else: + print(f"{nombre} est premier.") +``` + +Sortie : + +```text +97 est premier. +``` + +Attention à l'indentation : le `else` est aligné avec le **`for`**, pas avec le `if`. Il appartient à la boucle. + +> 💡 Pense le `else` de boucle comme un « sinon, si on n'a pas fait `break` » (*no break*). Sans lui, il faudrait une variable supplémentaire du style `trouve = False`, mise à `True` avant le `break`, puis testée après la boucle. Les deux approches sont correctes ; choisis celle qui te paraît la plus claire. + +### 8. Les boucles imbriquées + +Une boucle peut contenir une autre boucle. Pour **chaque tour** de la boucle extérieure, la boucle intérieure fait **tous** ses tours. Pense aux aiguilles d'une horloge : pour chaque heure, l'aiguille des minutes fait un tour complet. + +```python +for heure in range(2): + for minute in range(0, 60, 20): + print(f"{heure}h{minute:02d}") +``` + +Sortie : + +```text +0h00 +0h20 +0h40 +1h00 +1h20 +1h40 +``` + +2 tours extérieurs × 3 tours intérieurs = 6 affichages. + +#### 8.1. Une table de multiplication complète + +```python +for ligne in range(1, 6): + for colonne in range(1, 6): + print(f"{ligne * colonne:4}", end="") + print() # retour à la ligne à la fin de chaque ligne du tableau +``` + +Sortie : + +```text + 1 2 3 4 5 + 2 4 6 8 10 + 3 6 9 12 15 + 4 8 12 16 20 + 5 10 15 20 25 +``` + +Note l'emplacement du `print()` vide : il est dans la boucle extérieure (4 espaces), **après** la boucle intérieure. Il s'exécute donc une fois par ligne. `{...:4}` réserve 4 caractères pour chaque nombre, ce qui aligne les colonnes. + +#### 8.2. Dessiner des motifs + +Les boucles imbriquées permettent de dessiner des formes. Un triangle : + +```python +hauteur = 5 +for ligne in range(1, hauteur + 1): + for _ in range(ligne): + print("*", end="") + print() +``` + +Sortie : + +```text +* +** +*** +**** +***** +``` + +La boucle intérieure dépend de la variable de la boucle extérieure : à la ligne 1, une étoile ; à la ligne 2, deux étoiles, etc. Grâce à la répétition de chaînes (chapitre 01), on peut d'ailleurs remplacer la boucle intérieure par `print("*" * ligne)`. + +#### 8.3. `break` dans des boucles imbriquées + +`break` ne fait sortir que de la boucle **la plus proche** (la plus intérieure) : + +```python +for a in range(1, 4): + for b in range(1, 4): + if b > a: + break # ne quitte que la boucle sur b + print(f"({a},{b})", end=" ") + print() +``` + +Sortie : + +```text +(1,1) +(2,1) (2,2) +(3,1) (3,2) (3,3) +``` + +Pour sortir de **toutes** les boucles d'un coup, la solution la plus propre sera de placer le code dans une fonction et d'utiliser `return` (voir le [chapitre 05](05-fonctions.md)). + +### 9. Exemple complet : un menu interactif + +Rassemblons tout : un programme qui tourne jusqu'à ce qu'on choisisse de quitter, avec `while True`, `match`, `break` et des boucles `for`. + +```python +# Petit menu interactif +while True: + print() + print("=== MENU ===") + print("1. Table de multiplication") + print("2. Compte à rebours") + print("3. Quitter") + choix = input("Ton choix : ").strip() + + match choix: + case "1": + n = int(input("Quelle table ? ")) + for i in range(1, 11): + print(f"{n} x {i:2} = {n * i:3}") + case "2": + depart = int(input("Partir de combien ? ")) + for i in range(depart, 0, -1): + print(i, end=" ") + print("Partez !") + case "3": + print("Au revoir !") + break + case _: + print("Choix invalide, recommence.") +``` + +Exemple d'exécution : + +```text + +=== MENU === +1. Table de multiplication +2. Compte à rebours +3. Quitter +Ton choix : 2 +Partir de combien ? 5 +5 4 3 2 1 Partez ! + +=== MENU === +1. Table de multiplication +2. Compte à rebours +3. Quitter +Ton choix : 7 +Choix invalide, recommence. + +=== MENU === +1. Table de multiplication +2. Compte à rebours +3. Quitter +Ton choix : 3 +Au revoir ! +``` + +Remarque : le `break` placé dans un `case` fait bien sortir de la boucle `while` (un `match` n'est pas une boucle). + +--- + +## Pièges fréquents + +### Piège 1 — Oublier les deux-points + +```python +for i in range(3) + print(i) +``` + +```text + File "boucles.py", line 1 + for i in range(3) + ^ +SyntaxError: expected ':' +``` + +**Correction :** `for i in range(3):` et `while condition:` se terminent toujours par `:`. + +### Piège 2 — Se tromper d'une unité avec `range()` + +```python +# Objectif : afficher de 1 à 5 +for i in range(5): + print(i, end=" ") +print() +for i in range(1, 5): + print(i, end=" ") +print() +``` + +Sortie : + +```text +0 1 2 3 4 +1 2 3 4 +``` + +Aucun des deux n'affiche de 1 à 5 ! C'est l'erreur « d'une unité » (*off-by-one*), un grand classique, même chez les pros. + +**Correction :** `range(1, 6)`, ou plus généralement `range(1, n + 1)` pour aller de 1 à `n`. + +### Piège 3 — Passer une chaîne ou un float à `range()` + +```python +n = input("n : ") +for i in range(n): + print(i) +``` + +```text +n : 3 +Traceback (most recent call last): + File "boucles.py", line 2, in + for i in range(n): + ~~~~~^^^ +TypeError: 'str' object cannot be interpreted as an integer +``` + +Avec un float, comme `range(3.5)`, on obtient `TypeError: 'float' object cannot be interpreted as an integer`. + +**Correction :** convertis avec `int()` : `n = int(input("n : "))`. + +### Piège 4 — Boucler sur un nombre au lieu d'un `range` + +```python +for i in 5: + print(i) +``` + +```text +Traceback (most recent call last): + File "boucles.py", line 1, in + for i in 5: + ^ +TypeError: 'int' object is not iterable +``` + +Un nombre seul n'est pas « parcourable » (*iterable*) : il ne contient pas plusieurs éléments. + +**Correction :** `for i in range(5):`. + +### Piège 5 — La boucle `while` infinie + +```python +compteur = 0 +while compteur < 5: + print("Tour", compteur) +``` + +Le programme ne s'arrête jamais, car `compteur` vaut toujours 0. + +**Correction :** appuie sur `Ctrl` + `C` pour l'arrêter, puis ajoute `compteur += 1` dans le bloc. Vérifie toujours que quelque chose, dans le bloc, finira par rendre la condition fausse (ou déclenchera un `break`). + +### Piège 6 — Initialiser l'accumulateur dans la boucle + +```python +for nombre in range(1, 4): + total = 0 + total += nombre +print(total) +``` + +Sortie : + +```text +3 +``` + +On attendait 6 (1 + 2 + 3), mais `total` est remis à 0 à chaque tour : à la fin, il ne contient que le dernier nombre. + +**Correction :** place `total = 0` **avant** la boucle. + +### Piège 7 — Une instruction mal indentée + +```python +total = 0 +for i in range(1, 4): + total += i + print(f"Total : {total}") +``` + +Sortie : + +```text +Total : 1 +Total : 3 +Total : 6 +``` + +Si tu ne voulais afficher que le résultat final, le `print` est indenté d'un niveau de trop : il est exécuté à chaque tour. + +**Correction :** désindente-le pour le sortir de la boucle : + +```python +total = 0 +for i in range(1, 4): + total += i +print(f"Total : {total}") +``` + +Sortie : + +```text +Total : 6 +``` + +### Piège 8 — `break` en dehors d'une boucle + +```python +reponse = "non" +if reponse == "non": + break +``` + +```text + File "boucles.py", line 3 + break + ^^^^^ +SyntaxError: 'break' outside loop +``` + +`break` et `continue` n'ont de sens qu'à l'intérieur d'une boucle `for` ou `while`. (Pour arrêter tout le programme, il existe `exit()`, mais on préfère généralement organiser son code pour qu'il se termine naturellement.) + +--- + +## Exercices + +### Exercice 1 ⭐ — Table de multiplication + +Écris un programme qui demande un nombre et affiche sa table de multiplication de 1 à 10, avec les résultats alignés à droite. + +Exemple d'exécution : + +```text +Quelle table ? 7 + 7 x 1 = 7 + 7 x 2 = 14 + 7 x 3 = 21 + 7 x 4 = 28 + 7 x 5 = 35 + 7 x 6 = 42 + 7 x 7 = 49 + 7 x 8 = 56 + 7 x 9 = 63 + 7 x 10 = 70 +``` + +### Exercice 2 ⭐ — Somme et nombres pairs + +Écris un programme qui demande un entier `n` positif, puis affiche : + +1. la somme des entiers de 1 à `n` ; +2. la liste des nombres pairs de 2 à `n` sur une seule ligne, séparés par des espaces ; +3. combien il y a de multiples de 7 entre 1 et `n`. + +Exemple d'exécution : + +```text +n = 20 +Somme de 1 à 20 : 210 +Nombres pairs : 2 4 6 8 10 12 14 16 18 20 +Multiples de 7 : 2 +``` + +### Exercice 3 ⭐ — Analyse d'un mot + +Écris un programme qui demande un mot et affiche le nombre de voyelles, le nombre de consonnes, et le mot écrit avec des tirets entre les lettres. On considère que les voyelles sont `aeiouy` (en minuscules ou majuscules) et que tout autre caractère alphabétique est une consonne (indice : `caractere.isalpha()` renvoie `True` si le caractère est une lettre). + +Exemple d'exécution : + +```text +Un mot : Python +Voyelles : 2 +Consonnes : 4 +Épelé : P-y-t-h-o-n +``` + +### Exercice 4 ⭐⭐ — La saisie obstinée + +Écris un programme qui demande un nombre entier entre 1 et 10. Tant que la saisie n'est pas dans l'intervalle, il affiche un message d'erreur et redemande. Au bout de **3 essais ratés**, il abandonne avec « Trop d'erreurs, au revoir. ». En cas de succès, il affiche le nombre et le nombre d'essais utilisés. + +Exemples d'exécution : + +```text +Un nombre entre 1 et 10 : 15 +Hors limites ! (2 essai(s) restant(s)) +Un nombre entre 1 et 10 : 0 +Hors limites ! (1 essai(s) restant(s)) +Un nombre entre 1 et 10 : 4 +Merci ! Tu as choisi 4 en 3 essai(s). +``` + +```text +Un nombre entre 1 et 10 : 11 +Hors limites ! (2 essai(s) restant(s)) +Un nombre entre 1 et 10 : 12 +Hors limites ! (1 essai(s) restant(s)) +Un nombre entre 1 et 10 : 13 +Trop d'erreurs, au revoir. +``` + +### Exercice 5 ⭐⭐ — FizzBuzz + +Le grand classique des entretiens d'embauche ! Affiche les nombres de 1 à 20, mais : + +- remplace les multiples de 3 par `Fizz` ; +- remplace les multiples de 5 par `Buzz` ; +- remplace les multiples de 3 **et** de 5 par `FizzBuzz`. + +Le programme affiche un élément par ligne. Voici la suite attendue, écrite ici sur une seule ligne pour gagner de la place : + +```text +1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz, 16, 17, Fizz, 19, Buzz +``` + +### Exercice 6 ⭐⭐ — Nombres premiers + +Écris un programme qui demande un entier `limite` et affiche tous les nombres premiers de 2 à `limite`, sur une ligne, puis combien il y en a. Utilise deux boucles imbriquées et le `else` de boucle. + +Exemple d'exécution : + +```text +Limite : 50 +2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 +Il y a 15 nombres premiers jusqu'à 50. +``` + +Bonus : il suffit de tester les diviseurs jusqu'à la racine carrée du nombre (`diviseur * diviseur <= nombre`). Comment l'intégrer ? + +### Exercice 7 ⭐⭐⭐ — Le sapin de Noël + +Écris un programme qui demande une hauteur et dessine un sapin centré, avec un tronc. + +Exemple d'exécution : + +```text +Hauteur du sapin : 5 + * + *** + ***** + ******* +********* + | +``` + +Indice : à la ligne numéro `i` (en commençant à 0), il y a `hauteur - 1 - i` espaces puis `2 * i + 1` étoiles. + +### Exercice 8 ⭐⭐⭐ — La suite de Syracuse + +On part d'un entier positif `n`. À chaque étape : si `n` est pair, on le divise par 2 ; sinon, on le multiplie par 3 et on ajoute 1. Une célèbre conjecture (jamais démontrée !) affirme qu'on finit toujours par atteindre 1. + +Écris un programme qui demande `n`, affiche toute la suite jusqu'à 1, puis le nombre d'étapes et la valeur maximale atteinte (« l'altitude »). + +Exemple d'exécution : + +```text +Nombre de départ : 6 +6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1 +Étapes : 8 +Altitude maximale : 16 +``` + +Essaie ensuite avec 27 : tu seras surpris ! + +--- + +## Mini-projet + +### Le jeu du nombre mystère + +**Objectif :** programmer le jeu classique où l'ordinateur choisit un nombre au hasard et où le joueur doit le deviner, guidé par des indices « plus grand » / « plus petit ». + +Pour tirer un nombre au hasard, on utilise le **module** `random`. Les modules seront expliqués au [chapitre 09](09-modules-bibliotheque-standard.md) ; pour l'instant, recopie simplement ces deux lignes : + +```python +import random + +nombre_mystere = random.randint(1, 100) # entier au hasard entre 1 et 100 inclus +``` + +(La ligne `import random` se place tout en haut du fichier.) + +**Cahier des charges :** + +1. Le programme affiche les règles, puis tire un nombre entre 1 et 100. +2. Le joueur a **7 essais** au maximum. À chaque essai, le programme affiche le numéro de l'essai, demande une proposition et répond « C'est plus ! », « C'est moins ! » ou annonce la victoire avec le nombre d'essais utilisés. +3. Une proposition hors de l'intervalle 1–100 affiche un avertissement et **ne compte pas** comme un essai. +4. Si le joueur épuise ses essais, le programme révèle le nombre mystère. +5. À la fin d'une partie, le programme demande « Rejouer (o/n) ? » et recommence une nouvelle partie tant que le joueur répond `o`. +6. Quand le joueur arrête, le programme affiche le nombre de parties jouées, le nombre de victoires et le meilleur score (le plus petit nombre d'essais pour une victoire), ou « aucune victoire » s'il n'a jamais gagné. + +**Exemple d'exécution :** + +```text +=== LE NOMBRE MYSTÈRE === +Je pense à un nombre entre 1 et 100. Tu as 7 essais. + +Essai 1/7 : 50 +C'est moins ! +Essai 2/7 : 25 +C'est plus ! +Essai 3/7 : 150 +Entre 1 et 100, s'il te plaît (essai non compté). +Essai 3/7 : 37 +Bravo ! Trouvé en 3 essai(s). +Rejouer (o/n) ? n + +Parties jouées : 1 +Victoires : 1 +Meilleur score : 3 essai(s) +``` + +**Étapes suggérées :** + +1. Commence par une seule partie, sans limite d'essais : une boucle `while True` qui s'arrête (`break`) quand le joueur trouve. +2. Ajoute le compteur d'essais et la limite de 7. Le `else` de boucle (ou une variable `gagne`) permet de détecter la défaite. +3. Ajoute le contrôle de l'intervalle avec `continue`. +4. Enveloppe le tout dans une deuxième boucle, extérieure, pour rejouer. Attention à bien réinitialiser le nombre mystère et le compteur d'essais à chaque nouvelle partie. +5. Ajoute les statistiques, initialisées **avant** la boucle extérieure. +6. Pour tester facilement, affiche temporairement le nombre mystère au début de la partie (et pense à retirer cette ligne ensuite !). + +**Pistes d'amélioration :** + +- Propose trois niveaux de difficulté (1–50 en 10 essais, 1–100 en 7 essais, 1–1000 en 10 essais) avec `match`. +- Affiche « Tu chauffes ! » quand le joueur est à moins de 5 du nombre. +- Inverse les rôles : c'est toi qui penses à un nombre, et l'ordinateur doit le trouver en répondant à tes indications. Avec la bonne stratégie (couper l'intervalle en deux à chaque fois, la « recherche dichotomique »), il trouve toujours en 7 essais au plus ! +- Quand tu auras vu le chapitre 07, enregistre le meilleur score dans un fichier pour le conserver d'une session à l'autre. + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +```python +# Table de multiplication +n = int(input("Quelle table ? ")) +for i in range(1, 11): + print(f"{n:2} x {i:2} = {n * i:2}") +``` + +Exemple d'exécution : + +```text +Quelle table ? 7 + 7 x 1 = 7 + 7 x 2 = 14 + 7 x 3 = 21 + 7 x 4 = 28 + 7 x 5 = 35 + 7 x 6 = 42 + 7 x 7 = 49 + 7 x 8 = 56 + 7 x 9 = 63 + 7 x 10 = 70 +``` + +`range(1, 11)` va bien de 1 à 10. `{i:2}` réserve 2 caractères : les nombres (alignés à droite par défaut) forment des colonnes propres. + +
+ +
+Corrigé de l'exercice 2 + +```python +# Somme, nombres pairs et multiples de 7 +n = int(input("n = ")) + +# 1. Accumulateur +somme = 0 +for nombre in range(1, n + 1): + somme += nombre +print(f"Somme de 1 à {n} : {somme}") + +# 2. Nombres pairs : range avec un pas de 2 +print("Nombres pairs :", end="") +for nombre in range(2, n + 1, 2): + print(f" {nombre}", end="") +print() + +# 3. Compteur +nb_multiples_7 = 0 +for nombre in range(1, n + 1): + if nombre % 7 == 0: + nb_multiples_7 += 1 +print(f"Multiples de 7 : {nb_multiples_7}") +``` + +Exemple d'exécution : + +```text +n = 20 +Somme de 1 à 20 : 210 +Nombres pairs : 2 4 6 8 10 12 14 16 18 20 +Multiples de 7 : 2 +``` + +Pour les pairs, le pas de 2 évite de tester chaque nombre avec `% 2`. Pour les multiples de 7, on aurait pu faire de même avec `range(7, n + 1, 7)`, ou même calculer directement `n // 7` ! Il y a souvent plusieurs solutions. + +
+ +
+Corrigé de l'exercice 3 + +```python +# Analyse d'un mot +mot = input("Un mot : ") + +nb_voyelles = 0 +nb_consonnes = 0 +epele = "" + +for caractere in mot: + if caractere.lower() in "aeiouy": + nb_voyelles += 1 + elif caractere.isalpha(): + nb_consonnes += 1 + + if epele == "": + epele = caractere + else: + epele += "-" + caractere + +print(f"Voyelles : {nb_voyelles}") +print(f"Consonnes : {nb_consonnes}") +print(f"Épelé : {epele}") +``` + +Exemple d'exécution : + +```text +Un mot : Python +Voyelles : 2 +Consonnes : 4 +Épelé : P-y-t-h-o-n +``` + +L'ordre `if`/`elif` est important : une voyelle est aussi une lettre (`isalpha()` vaut `True`), donc on teste d'abord les voyelles. Pour l'épellation, on n'ajoute le tiret qu'à partir de la deuxième lettre, pour éviter un tiret au début. (Au chapitre 06, tu découvriras `"-".join(mot)`, qui fait la même chose en une instruction.) + +
+ +
+Corrigé de l'exercice 4 + +```python +# Saisie validée avec un nombre d'essais limité +essais_max = 3 +essais = 0 + +while essais < essais_max: + nombre = int(input("Un nombre entre 1 et 10 : ")) + essais += 1 + if 1 <= nombre <= 10: + print(f"Merci ! Tu as choisi {nombre} en {essais} essai(s).") + break + restants = essais_max - essais + if restants > 0: + print(f"Hors limites ! ({restants} essai(s) restant(s))") +else: + print("Trop d'erreurs, au revoir.") +``` + +Exemples d'exécution : + +```text +Un nombre entre 1 et 10 : 15 +Hors limites ! (2 essai(s) restant(s)) +Un nombre entre 1 et 10 : 0 +Hors limites ! (1 essai(s) restant(s)) +Un nombre entre 1 et 10 : 4 +Merci ! Tu as choisi 4 en 3 essai(s). +``` + +```text +Un nombre entre 1 et 10 : 11 +Hors limites ! (2 essai(s) restant(s)) +Un nombre entre 1 et 10 : 12 +Hors limites ! (1 essai(s) restant(s)) +Un nombre entre 1 et 10 : 13 +Trop d'erreurs, au revoir. +``` + +Le `else` de la boucle `while` s'exécute quand la condition `essais < essais_max` devient fausse, c'est-à-dire quand on a épuisé les essais **sans** passer par le `break`. On n'affiche le message « Hors limites » que s'il reste des essais, pour ne pas écrire « 0 essai(s) restant(s) » juste avant « Trop d'erreurs ». + +
+ +
+Corrigé de l'exercice 5 + +```python +# FizzBuzz de 1 à 20 +for nombre in range(1, 21): + if nombre % 15 == 0: # multiple de 3 ET de 5 + print("FizzBuzz") + elif nombre % 3 == 0: + print("Fizz") + elif nombre % 5 == 0: + print("Buzz") + else: + print(nombre) +``` + +Sortie : + +```text +1 +2 +Fizz +4 +Buzz +Fizz +7 +8 +Fizz +Buzz +11 +Fizz +13 +14 +FizzBuzz +16 +17 +Fizz +19 +Buzz +``` + +Le piège : tester `% 15` (ou `% 3 == 0 and % 5 == 0`) **en premier**. Si on testait d'abord `% 3`, 15 afficherait « Fizz » et le cas « FizzBuzz » ne serait jamais atteint (rappel du chapitre 02 : avec `elif`, la première condition vraie gagne). + +
+ +
+Corrigé de l'exercice 6 + +```python +# Nombres premiers jusqu'à une limite +limite = int(input("Limite : ")) + +nb_premiers = 0 +for nombre in range(2, limite + 1): + for diviseur in range(2, nombre): + if nombre % diviseur == 0: + break # un diviseur trouvé : pas premier + else: + # aucun break : aucun diviseur trouvé, le nombre est premier + if nb_premiers > 0: + print(" ", end="") + print(nombre, end="") + nb_premiers += 1 +print() +print(f"Il y a {nb_premiers} nombres premiers jusqu'à {limite}.") +``` + +Exemple d'exécution : + +```text +Limite : 50 +2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 +Il y a 15 nombres premiers jusqu'à 50. +``` + +Pour `nombre = 2`, `range(2, 2)` est vide : la boucle intérieure ne fait aucun tour, donc aucun `break`, et le `else` s'exécute : 2 est bien premier. + +**Bonus :** si `nombre` a un diviseur, il en a forcément un inférieur ou égal à sa racine carrée. On peut donc arrêter la recherche plus tôt avec un `while` : + +```python +limite = 50 +nb_premiers = 0 +for nombre in range(2, limite + 1): + diviseur = 2 + while diviseur * diviseur <= nombre: + if nombre % diviseur == 0: + break + diviseur += 1 + else: + nb_premiers += 1 +print(f"Il y a {nb_premiers} nombres premiers jusqu'à {limite}.") +``` + +Sortie : + +```text +Il y a 15 nombres premiers jusqu'à 50. +``` + +Pour 97, on teste alors 8 diviseurs au lieu de 95 : sur de grands nombres, la différence est énorme. + +
+ +
+Corrigé de l'exercice 7 + +```python +# Sapin de Noël +hauteur = int(input("Hauteur du sapin : ")) + +for i in range(hauteur): + espaces = hauteur - 1 - i + etoiles = 2 * i + 1 + print(" " * espaces + "*" * etoiles) + +# Le tronc, centré sous la pointe +print(" " * (hauteur - 1) + "|") +``` + +Exemple d'exécution : + +```text +Hauteur du sapin : 5 + * + *** + ***** + ******* +********* + | +``` + +Ici, la répétition de chaînes (`" " * espaces`) remplace une boucle intérieure. Version avec de vraies boucles imbriquées, pour s'entraîner : + +```python +hauteur = 3 +for i in range(hauteur): + for _ in range(hauteur - 1 - i): + print(" ", end="") + for _ in range(2 * i + 1): + print("*", end="") + print() +``` + +Sortie : + +```text + * + *** +***** +``` + +
+ +
+Corrigé de l'exercice 8 + +```python +# Suite de Syracuse +n = int(input("Nombre de départ : ")) + +etapes = 0 +altitude = n +suite = str(n) + +while n != 1: + if n % 2 == 0: + n = n // 2 # division entière : n reste un int + else: + n = 3 * n + 1 + etapes += 1 + if n > altitude: + altitude = n + suite += f" → {n}" + +print(suite) +print(f"Étapes : {etapes}") +print(f"Altitude maximale : {altitude}") +``` + +Exemple d'exécution : + +```text +Nombre de départ : 6 +6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1 +Étapes : 8 +Altitude maximale : 16 +``` + +Avec 27, la suite fait 111 étapes et monte jusqu'à 9232 avant de redescendre ! C'est un cas idéal pour `while` : impossible de savoir à l'avance combien de tours seront nécessaires. On utilise `//` et pas `/`, sinon `n` deviendrait un float (`3.0`, `10.0`…). On combine trois motifs : un compteur (`etapes`), une recherche de maximum (`altitude`) et un accumulateur de chaîne (`suite`). + +
+ +
+Corrigé du mini-projet + +```python +# nombre_mystere.py — Jeu du nombre mystère +import random + +ESSAIS_MAX = 7 + +print("=== LE NOMBRE MYSTÈRE ===") + +# Statistiques sur l'ensemble des parties +nb_parties = 0 +nb_victoires = 0 +meilleur_score = 0 # 0 signifie « pas encore de victoire » + +while True: # boucle des parties + nombre_mystere = random.randint(1, 100) + nb_parties += 1 + essai = 1 + print(f"Je pense à un nombre entre 1 et 100. Tu as {ESSAIS_MAX} essais.") + print() + + while essai <= ESSAIS_MAX: # boucle des essais d'une partie + proposition = int(input(f"Essai {essai}/{ESSAIS_MAX} : ")) + + if proposition < 1 or proposition > 100: + print("Entre 1 et 100, s'il te plaît (essai non compté).") + continue # on redemande sans incrémenter essai + + if proposition < nombre_mystere: + print("C'est plus !") + elif proposition > nombre_mystere: + print("C'est moins !") + else: + print(f"Bravo ! Trouvé en {essai} essai(s).") + nb_victoires += 1 + if meilleur_score == 0 or essai < meilleur_score: + meilleur_score = essai + break + essai += 1 + else: + # la boucle s'est terminée sans break : le joueur a perdu + print(f"Perdu ! Le nombre mystère était {nombre_mystere}.") + + rejouer = input("Rejouer (o/n) ? ").strip().lower() + if rejouer != "o": + break + print() + +# Bilan +print() +print(f"Parties jouées : {nb_parties}") +print(f"Victoires : {nb_victoires}") +if meilleur_score == 0: + print("Meilleur score : aucune victoire") +else: + print(f"Meilleur score : {meilleur_score} essai(s)") +``` + +Exemple d'exécution (le nombre tiré était 37) : + +```text +=== LE NOMBRE MYSTÈRE === +Je pense à un nombre entre 1 et 100. Tu as 7 essais. + +Essai 1/7 : 50 +C'est moins ! +Essai 2/7 : 25 +C'est plus ! +Essai 3/7 : 150 +Entre 1 et 100, s'il te plaît (essai non compté). +Essai 3/7 : 37 +Bravo ! Trouvé en 3 essai(s). +Rejouer (o/n) ? n + +Parties jouées : 1 +Victoires : 1 +Meilleur score : 3 essai(s) +``` + +Points clés de la solution : + +- **Deux boucles imbriquées** : la boucle extérieure gère les parties, la boucle intérieure les essais d'une partie. Le nombre mystère et le compteur `essai` sont réinitialisés **au début de chaque partie**, les statistiques **une seule fois avant** toutes les parties. +- **`continue`** pour une saisie hors limites : on repart au début de la boucle **sans** exécuter `essai += 1`, donc l'essai n'est pas compté. +- **`break`** en cas de victoire, et le **`else` de boucle** pour détecter la défaite. +- Le second `break`, après « Rejouer », sort de la boucle **extérieure** (c'est la boucle la plus proche de lui). +- `ESSAIS_MAX` est écrit en majuscules : c'est la convention pour une **constante**, une valeur qu'on ne modifie jamais pendant le programme. La changer ici modifie tout le jeu. +- Pour la variante où l'ordinateur devine, garde deux variables `borne_min` et `borne_max`, propose `(borne_min + borne_max) // 2`, et resserre l'intervalle selon la réponse. + +
+ +--- + +## Récapitulatif + +**Points clés :** + +- `for variable in iterable:` répète le bloc pour chaque élément d'une plage de nombres, d'une chaîne ou d'une liste. +- `range(fin)`, `range(debut, fin)`, `range(debut, fin, pas)` : la fin est **toujours exclue**. De 1 à `n` inclus : `range(1, n + 1)`. +- `while condition:` répète tant que la condition est vraie. Il faut initialiser la variable testée avant, et la modifier dans le bloc. `Ctrl` + `C` arrête une boucle infinie. +- `for` quand on sait sur quoi boucler ; `while` quand le nombre de tours est inconnu. +- `break` sort immédiatement de la boucle (la plus proche) ; `continue` passe au tour suivant. +- Le `else` d'une boucle s'exécute si elle se termine **sans `break`** : idéal pour les recherches. +- Motifs à connaître : **accumulateur** (`total = 0` avant, `total += x` dedans), **compteur**, **recherche de maximum**, **saisie validée** avec `while True` + `break`. +- Dans des boucles imbriquées, la boucle intérieure fait tous ses tours pour chaque tour de la boucle extérieure. +- L'indentation décide de ce qui est répété : une ligne mal indentée est exécutée trop souvent, ou pas assez. + +**Checklist de validation :** + +- [ ] Je sais afficher les nombres de 1 à `n`, de `n` à 1 et de 2 en 2 avec `range()`. +- [ ] Je sais parcourir les lettres d'un mot et les éléments d'une liste simple avec `for`. +- [ ] Je sais écrire un `while` qui se termine toujours, et arrêter une boucle infinie. +- [ ] Je sais calculer une somme, compter des éléments et trouver un maximum avec une boucle. +- [ ] Je sais redemander une saisie tant qu'elle est invalide. +- [ ] Je sais expliquer la différence entre `break` et `continue`, et quand le `else` d'une boucle s'exécute. +- [ ] J'ai programmé le jeu du nombre mystère avec la possibilité de rejouer. + +--- + +[← Chapitre précédent](02-conditions.md) | [Sommaire](README.md) | [Chapitre suivant →](04-structures-de-donnees.md) diff --git a/docs/presentations/Python/04-structures-de-donnees.md b/docs/presentations/Python/04-structures-de-donnees.md new file mode 100644 index 0000000..f894962 --- /dev/null +++ b/docs/presentations/Python/04-structures-de-donnees.md @@ -0,0 +1,1717 @@ +# Chapitre 04 — Les structures de données : listes, tuples, dictionnaires, ensembles + +> **Durée estimée :** 5 à 6 heures (cours, exercices et mini-projet) +> +> **Prérequis :** [Chapitre 03 — Les boucles](03-boucles.md) (`for`, `while`, `range`) +> +> **Objectifs — à la fin de ce chapitre, tu sauras…** +> +> - créer, lire, modifier et parcourir une **liste** ; +> - comprendre la différence entre **copier** une liste et lui donner un **deuxième nom** ; +> - utiliser un **tuple** et « déballer » plusieurs valeurs d'un coup ; +> - ranger des informations par nom grâce aux **dictionnaires** ; +> - éliminer des doublons et comparer des collections avec les **ensembles** ; +> - choisir la structure adaptée à chaque problème. + +--- + +## Cours + +### 1. Pourquoi des structures de données ? + +Jusqu'ici, chaque variable contenait **une seule** valeur : un nombre, un texte, un booléen. Imagine que tu veuilles retenir les notes de toute une classe : + +```python +note1 = 12 +note2 = 15 +note3 = 9 +note4 = 17 +# ... et s'il y a 30 élèves ? 300 ? +``` + +C'est vite ingérable : impossible de faire une boucle sur `note1`, `note2`, `note3`… Il nous faut une variable capable de contenir **plusieurs valeurs** à la fois. C'est le rôle des **structures de données** (on dit aussi **collections**). + +Python en propose quatre, que tu utiliseras en permanence : + +| Structure | Exemple | Analogie | +|---|---|---| +| liste (`list`) | `[12, 15, 9]` | une étagère numérotée où l'on peut ajouter, retirer, réordonner | +| tuple (`tuple`) | `(48.85, 2.35)` | une fiche plastifiée : on la lit, on ne la modifie pas | +| dictionnaire (`dict`) | `{"Alice": 12, "Bob": 15}` | un répertoire : on cherche par **nom**, pas par numéro | +| ensemble (`set`) | `{"rouge", "vert"}` | un sac de billes toutes différentes, sans ordre | + +--- + +### 2. Les listes : créer et lire + +Une **liste** est une suite ordonnée d'éléments, écrite entre crochets `[ ]`, les éléments étant séparés par des virgules. + +```python +notes = [12, 15, 9, 17] +prenoms = ["Alice", "Bob", "Chloé"] +vide = [] +melange = [42, "texte", 3.14, True] # possible, mais rarement une bonne idée + +print(notes) +print(prenoms) +print(len(notes)) # len() donne le nombre d'éléments +print(vide) +``` + +```text +[12, 15, 9, 17] +['Alice', 'Bob', 'Chloé'] +4 +[] +``` + +> Python affiche les chaînes d'une liste avec des apostrophes `'…'` : c'est normal, c'est sa façon de te montrer qu'il s'agit de textes. + +#### Les indices : chaque case a un numéro + +Chaque élément a une **position**, appelée **indice**. Attention : **on commence à compter à 0** ! + +```text + indice : 0 1 2 3 + notes : [ 12, 15, 9, 17 ] + indice : -4 -3 -2 -1 +``` + +```python +notes = [12, 15, 9, 17] +print(notes[0]) # → 12 +print(notes[2]) # → 9 +print(notes[-1]) # → 17 +print(notes[-2]) # → 9 +``` + +Les **indices négatifs** comptent depuis la fin : `-1` est le dernier élément, `-2` l'avant-dernier… Très pratique quand on ne connaît pas la longueur de la liste. + +Si tu demandes une case qui n'existe pas, Python se fâche : + +```python +notes = [12, 15, 9, 17] +print(notes[4]) +``` + +```text +Traceback (most recent call last): + File "", line 2, in +IndexError: list index out of range +``` + +Une liste de 4 éléments a des indices de `0` à `3`. Le dernier indice valide est toujours `len(liste) - 1`. + +#### Modifier un élément + +Une liste est **modifiable** (on dit **mutable**) : on peut remplacer un élément en l'affectant par son indice. + +```python +notes = [12, 15, 9, 17] +notes[2] = 11 # on corrige la 3e note +print(notes) # → [12, 15, 11, 17] +``` + +--- + +### 3. Ajouter et retirer des éléments + +Les listes viennent avec des **méthodes** : des actions qu'on appelle avec un point, `liste.methode(...)`. Voici les plus utiles pour faire grandir ou rétrécir une liste. + +#### Ajouter + +```python +courses = ["pain", "lait"] + +courses.append("oeufs") # ajoute À LA FIN +print(courses) # → ['pain', 'lait', 'oeufs'] + +courses.insert(0, "café") # insère à l'indice 0 (au début) +print(courses) # → ['café', 'pain', 'lait', 'oeufs'] + +courses.extend(["riz", "pâtes"]) # ajoute PLUSIEURS éléments à la fin +print(courses) # → ['café', 'pain', 'lait', 'oeufs', 'riz', 'pâtes'] +``` + +On peut aussi **concaténer** deux listes avec `+` (cela crée une **nouvelle** liste) ou répéter une liste avec `*` : + +```python +a = [1, 2] +b = [3, 4] +print(a + b) # → [1, 2, 3, 4] +print(a * 3) # → [1, 2, 1, 2, 1, 2] +print(a) # → [1, 2] +``` + +#### Retirer + +```python +courses = ["café", "pain", "lait", "oeufs", "lait"] + +courses.remove("lait") # retire la PREMIÈRE occurrence de "lait" +print(courses) # → ['café', 'pain', 'oeufs', 'lait'] + +dernier = courses.pop() # retire ET renvoie le dernier élément +print(dernier) # → lait +print(courses) # → ['café', 'pain', 'oeufs'] + +premier = courses.pop(0) # retire ET renvoie l'élément d'indice 0 +print(premier) # → café + +del courses[0] # supprime l'élément d'indice 0 (sans le renvoyer) +print(courses) # → ['oeufs'] + +courses.clear() # vide complètement la liste +print(courses) # → [] +``` + +Résumé : + +| Méthode | Effet | +|---|---| +| `liste.append(x)` | ajoute `x` à la fin | +| `liste.insert(i, x)` | insère `x` à l'indice `i` | +| `liste.extend(autre)` | ajoute tous les éléments de `autre` à la fin | +| `liste.remove(x)` | retire la première occurrence de `x` (erreur si absent) | +| `liste.pop()` / `liste.pop(i)` | retire et **renvoie** le dernier élément / celui d'indice `i` | +| `del liste[i]` | supprime l'élément d'indice `i` | +| `liste.clear()` | vide la liste | + +--- + +### 4. Rechercher dans une liste + +#### Le mot-clé `in` + +Pour savoir si un élément est présent, on utilise `in` (et `not in`), qui renvoie un booléen. Tu peux donc l'utiliser directement dans un `if` : + +```python +fruits = ["pomme", "banane", "kiwi"] +print("kiwi" in fruits) # → True +print("mangue" in fruits) # → False +print("mangue" not in fruits) # → True + +if "banane" in fruits: + print("Il reste des bananes !") # → Il reste des bananes ! +``` + +#### `index` et `count` + +```python +lettres = ["a", "b", "c", "b", "a", "b"] +print(lettres.index("c")) # → 2 +print(lettres.count("b")) # → 3 +print(lettres.count("z")) # → 0 +``` + +`index()` provoque une erreur si l'élément est absent (`ValueError: 'z' is not in list`) : vérifie d'abord avec `in`. + +#### Les fonctions `min`, `max`, `sum` + +Ces fonctions intégrées travaillent sur une liste entière : + +```python +notes = [12, 15, 9, 17, 14] +print(min(notes)) # → 9 +print(max(notes)) # → 17 +print(sum(notes)) # → 67 +moyenne = sum(notes) / len(notes) +print(f"Moyenne : {moyenne:.2f}") # → Moyenne : 13.40 +``` + +--- + +### 5. Parcourir une liste + +#### Avec `for` : la façon naturelle + +Tu as vu `for` au chapitre 03 avec `range`. En réalité, `for` sait parcourir **n'importe quelle collection** : à chaque tour, la variable prend la valeur de l'élément suivant. + +```python +prenoms = ["Alice", "Bob", "Chloé"] +for prenom in prenoms: + print(f"Bonjour {prenom} !") +``` + +```text +Bonjour Alice ! +Bonjour Bob ! +Bonjour Chloé ! +``` + +Lis-le comme une phrase : « pour chaque prénom dans la liste des prénoms, affiche… ». Choisis un nom au singulier pour la variable de boucle et au pluriel pour la liste : le code devient lisible. + +#### Quand on a besoin de l'indice + +Parfois, tu veux aussi la position. La façon simple avec ce que tu connais : `range(len(liste))`. + +```python +podium = ["Alice", "Bob", "Chloé"] +for i in range(len(podium)): + print(f"{i + 1}. {podium[i]}") +``` + +```text +1. Alice +2. Bob +3. Chloé +``` + +> Python propose une fonction plus élégante pour cela, `enumerate()`. On la verra au [chapitre 06](06-chaines-et-comprehensions.md). + +#### Construire une liste au fil d'une boucle + +Un schéma ultra-courant : partir d'une liste vide et la remplir avec `append`. + +```python +notes = [12, 7, 15, 9, 18, 10] +notes_admises = [] +for note in notes: + if note >= 10: + notes_admises.append(note) +print(notes_admises) # → [12, 15, 18, 10] +``` + +Et pour un **accumulateur** (total, compteur), c'est le même principe qu'au chapitre 03 : + +```python +temperatures = [18.5, 21.0, 19.5, 23.0] +total = 0 +nb_chaudes = 0 +for temperature in temperatures: + total += temperature + if temperature > 20: + nb_chaudes += 1 +print(f"Moyenne : {total / len(temperatures)}") # → Moyenne : 20.5 +print(f"Jours chauds : {nb_chaudes}") # → Jours chauds : 2 +``` + +#### Remplir une liste avec `input()` + +```python +achats = [] +saisie = input("Article (vide pour finir) : ") +while saisie != "": + achats.append(saisie) + saisie = input("Article (vide pour finir) : ") +print(f"Tu as noté {len(achats)} article(s) : {achats}") +``` + +```text +Article (vide pour finir) : tomates +Article (vide pour finir) : basilic +Article (vide pour finir) : +Tu as noté 2 article(s) : ['tomates', 'basilic'] +``` + +--- + +### 6. Trier et inverser + +#### `sort()` : trier la liste elle-même + +```python +nombres = [5, 2, 9, 1, 7] +nombres.sort() +print(nombres) # → [1, 2, 5, 7, 9] +nombres.sort(reverse=True) # ordre décroissant +print(nombres) # → [9, 7, 5, 2, 1] +``` + +`sort()` **modifie** la liste et ne renvoie rien (elle renvoie `None`, voir les pièges plus bas). + +#### `sorted()` : obtenir une copie triée + +```python +nombres = [5, 2, 9, 1, 7] +tries = sorted(nombres) +print(tries) # → [1, 2, 5, 7, 9] +print(nombres) # → [5, 2, 9, 1, 7] +``` + +`sorted()` laisse l'original intact et renvoie une **nouvelle** liste. Les chaînes se trient par ordre alphabétique… avec une subtilité : les majuscules passent avant les minuscules. + +```python +mots = ["poire", "Banane", "abricot", "Cerise"] +print(sorted(mots)) # → ['Banane', 'Cerise', 'abricot', 'poire'] +print(sorted(mots, key=len)) # → ['poire', 'Banane', 'Cerise', 'abricot'] +``` + +Le paramètre `key=len` signifie « trie selon la longueur ». On approfondira `sorted` et `key` au [chapitre 06](06-chaines-et-comprehensions.md). + +#### `reverse()` : inverser l'ordre + +```python +etapes = ["départ", "milieu", "arrivée"] +etapes.reverse() +print(etapes) # → ['arrivée', 'milieu', 'départ'] +``` + +--- + +### 7. Le découpage (slicing) : extraire une portion + +On peut extraire un **morceau** de liste avec la syntaxe `liste[debut:fin]`. L'élément d'indice `debut` est inclus, celui d'indice `fin` est **exclu** (comme avec `range`). + +```python +lettres = ["a", "b", "c", "d", "e", "f"] +print(lettres[1:4]) # → ['b', 'c', 'd'] +print(lettres[:3]) # → ['a', 'b', 'c'] +print(lettres[3:]) # → ['d', 'e', 'f'] +print(lettres[-2:]) # → ['e', 'f'] +print(lettres[::2]) # → ['a', 'c', 'e'] +print(lettres[::-1]) # → ['f', 'e', 'd', 'c', 'b', 'a'] +``` + +- Si on omet `debut`, on part du début ; si on omet `fin`, on va jusqu'au bout. +- Un troisième nombre donne le **pas** : `[::2]` prend un élément sur deux, `[::-1]` parcourt à l'envers. + +Le slicing fonctionne aussi sur les chaînes de caractères ; on l'étudiera en détail au [chapitre 06](06-chaines-et-comprehensions.md). + +--- + +### 8. Les listes de listes + +Une liste peut contenir… d'autres listes. C'est idéal pour représenter un **tableau** (une grille, une matrice, un plateau de jeu). + +```python +grille = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], +] +print(grille[0]) # → [1, 2, 3] +print(grille[1][2]) # → 6 +print(len(grille)) # → 3 +``` + +`grille[1][2]` se lit « dans la ligne d'indice 1, l'élément d'indice 2 ». Pour tout parcourir, on utilise deux boucles imbriquées (vues au chapitre 03) : + +```python +grille = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], +] +for ligne in grille: + for valeur in ligne: + print(valeur, end=" ") + print() # retour à la ligne après chaque ligne de la grille +``` + +```text +1 2 3 +4 5 6 +7 8 9 +``` + +Exemple concret : un petit tableau d'élèves avec leurs notes. + +```python +classe = [ + ["Alice", 14, 16], + ["Bob", 9, 12], + ["Chloé", 18, 15], +] +for eleve in classe: + nom = eleve[0] + moyenne = (eleve[1] + eleve[2]) / 2 + print(f"{nom} : {moyenne}") +``` + +```text +Alice : 15.0 +Bob : 10.5 +Chloé : 16.5 +``` + +--- + +### 9. Références et copies : le point le plus important du chapitre + +Voici un comportement qui surprend **tous** les débutants : + +```python +a = [1, 2, 3] +b = a +b.append(4) +print(a) # → [1, 2, 3, 4] +print(b) # → [1, 2, 3, 4] +``` + +On a modifié `b`… et `a` a changé aussi ! Pourquoi ? + +**Analogie :** une variable n'est pas une boîte qui *contient* la liste, c'est une **étiquette** collée sur la liste. L'instruction `b = a` ne fabrique pas une deuxième liste : elle colle une deuxième étiquette, `b`, sur **la même** liste. + +```text + a ──┐ + ├──► [1, 2, 3, 4] + b ──┘ +``` + +On peut le vérifier avec l'opérateur `is`, qui teste si deux noms désignent **le même objet** : + +```python +a = [1, 2, 3] +b = a +print(b is a) # → True +``` + +#### Faire une vraie copie + +Si tu veux une liste **indépendante**, il faut la copier explicitement. Trois façons équivalentes : + +```python +a = [1, 2, 3] +b = a.copy() # méthode copy() +c = list(a) # construire une nouvelle liste à partir de a +d = a[:] # slicing complet +b.append(4) +print(a) # → [1, 2, 3] +print(b) # → [1, 2, 3, 4] +print(c is a) # → False +print(c == a) # → True +``` + +Note la différence : `==` compare le **contenu**, `is` compare l'**identité** (même objet ou non). + +#### Et pour les nombres ou les chaînes ? + +Avec un `int` ou une `str`, ce problème ne se pose jamais : ces types sont **immuables** (on ne peut pas les modifier sur place). `x += 1` ne modifie pas le nombre, il crée un nouveau nombre et déplace l'étiquette. + +```python +x = 5 +y = x +x += 1 +print(x, y) # → 6 5 +``` + +#### Copie superficielle et listes imbriquées + +`copy()` ne copie que le **premier niveau**. Pour une liste de listes, les sous-listes restent partagées : + +```python +grille = [[1, 2], [3, 4]] +copie = grille.copy() +copie[0].append(99) +print(grille) # → [[1, 2, 99], [3, 4]] +``` + +Pour copier aussi les sous-listes, on utilise `deepcopy` du module `copy` (les modules seront vus au [chapitre 09](09-modules-bibliotheque-standard.md)) : + +```python +import copy + +grille = [[1, 2], [3, 4]] +copie = copy.deepcopy(grille) +copie[0].append(99) +print(grille) # → [[1, 2], [3, 4]] +print(copie) # → [[1, 2, 99], [3, 4]] +``` + +--- + +### 10. Les tuples : des listes figées + +Un **tuple** ressemble à une liste, mais il est **immuable** : une fois créé, on ne peut ni modifier, ni ajouter, ni retirer d'élément. On l'écrit entre parenthèses. + +```python +point = (3, 7) +couleur_rgb = (255, 128, 0) +print(point[0]) # → 3 +print(len(couleur_rgb)) # → 3 +print(128 in couleur_rgb) # → True +``` + +Tenter de le modifier provoque une erreur : + +```python +point = (3, 7) +point[0] = 10 +``` + +```text +Traceback (most recent call last): + File "", line 2, in +TypeError: 'tuple' object does not support item assignment +``` + +**À quoi bon ?** Un tuple sert à regrouper des valeurs qui **vont ensemble** et ne doivent pas changer : des coordonnées, une date `(14, 7, 1789)`, une couleur RGB… L'immuabilité est une **garantie** : personne ne pourra modifier ces données par erreur. + +#### Le tuple à un seul élément + +Piège classique : `(5)` n'est pas un tuple, c'est juste le nombre 5 entre parenthèses. Il faut une **virgule** : + +```python +pas_un_tuple = (5) +un_tuple = (5,) +print(type(pas_un_tuple)) # → +print(type(un_tuple)) # → +``` + +En fait, c'est la **virgule** qui fait le tuple, pas les parenthèses : `point = 3, 7` crée aussi un tuple. + +#### Le déballage (unpacking) + +On peut répartir les éléments d'un tuple (ou d'une liste) dans plusieurs variables d'un coup : + +```python +point = (3, 7) +x, y = point +print(x) # → 3 +print(y) # → 7 + +jour, mois, annee = (14, 7, 1789) +print(f"{jour}/{mois}/{annee}") # → 14/7/1789 +``` + +Il faut autant de variables que d'éléments, sinon : `ValueError: too many values to unpack`. + +L'astérisque `*` permet de récupérer « le reste » dans une liste : + +```python +premier, *autres = [10, 20, 30, 40] +print(premier) # → 10 +print(autres) # → [20, 30, 40] +``` + +Le déballage offre une astuce célèbre : **échanger deux variables** sans variable temporaire. + +```python +a = 1 +b = 2 +a, b = b, a +print(a, b) # → 2 1 +``` + +Et il est très pratique dans une boucle sur une liste de tuples : + +```python +villes = [("Paris", 2_100_000), ("Lyon", 520_000), ("Lille", 236_000)] +for nom, habitants in villes: + print(f"{nom} compte {habitants} habitants") +``` + +```text +Paris compte 2100000 habitants +Lyon compte 520000 habitants +Lille compte 236000 habitants +``` + +> Rappel : `2_100_000` est simplement une écriture plus lisible de `2100000` ; les `_` sont ignorés par Python. + +--- + +### 11. Les dictionnaires : chercher par clé + +Une liste range ses éléments par **numéro**. Mais souvent, on veut retrouver une information par un **nom** : le numéro de téléphone de Bob, le prix d'une baguette, la capitale de l'Italie… C'est le rôle du **dictionnaire**. + +**Analogie :** un vrai dictionnaire associe un **mot** (la clé) à sa **définition** (la valeur). On ne cherche pas « la définition n° 4127 », on cherche « le mot *chat* ». + +```python +prix = {"pain": 1.20, "lait": 0.95, "oeufs": 2.50} +print(prix["lait"]) # → 0.95 +print(len(prix)) # → 3 +print(prix) # → {'pain': 1.2, 'lait': 0.95, 'oeufs': 2.5} +``` + +- On écrit un dictionnaire entre accolades `{ }`, avec des paires `cle: valeur` séparées par des virgules. +- Les **clés** sont uniques et doivent être immuables (chaînes, nombres, tuples). En pratique, ce sont presque toujours des chaînes. +- Les **valeurs** peuvent être de n'importe quel type, y compris des listes ou d'autres dictionnaires. +- Depuis Python 3.7, un dictionnaire **conserve l'ordre d'insertion**. + +#### Ajouter, modifier, supprimer + +```python +stock = {"pommes": 10, "poires": 4} + +stock["kiwis"] = 7 # la clé n'existe pas : on l'AJOUTE +stock["pommes"] = 12 # la clé existe : on la MODIFIE +stock["poires"] += 3 # on peut utiliser += sur la valeur +print(stock) # → {'pommes': 12, 'poires': 7, 'kiwis': 7} + +del stock["kiwis"] # suppression +print(stock) # → {'pommes': 12, 'poires': 7} + +quantite = stock.pop("poires") # supprime ET renvoie la valeur +print(quantite) # → 7 +print(stock) # → {'pommes': 12} +``` + +#### Lire sans risque : `in` et `get` + +Accéder à une clé absente avec `[ ]` provoque une erreur `KeyError` : + +```python +stock = {"pommes": 10} +print(stock["bananes"]) +``` + +```text +Traceback (most recent call last): + File "", line 2, in +KeyError: 'bananes' +``` + +Deux solutions : + +```python +stock = {"pommes": 10} + +# 1. Vérifier avec in (in teste les CLÉS) +if "bananes" in stock: + print(stock["bananes"]) +else: + print("Pas de bananes") # → Pas de bananes + +# 2. Utiliser get(cle, valeur_par_defaut) +print(stock.get("bananes", 0)) # → 0 +print(stock.get("pommes", 0)) # → 10 +print(stock.get("bananes")) # → None +``` + +Sans valeur par défaut, `get` renvoie `None`, la valeur spéciale qui signifie « rien ». + +#### Parcourir un dictionnaire + +```python +capitales = {"France": "Paris", "Italie": "Rome", "Espagne": "Madrid"} + +for pays in capitales: # par défaut : on parcourt les CLÉS + print(pays) + +for ville in capitales.values(): # les valeurs + print(ville) + +for pays, ville in capitales.items(): # les paires (clé, valeur) + print(f"La capitale de {pays} est {ville}.") +``` + +```text +France +Italie +Espagne +Paris +Rome +Madrid +La capitale de France est Paris. +La capitale de Italie est Rome. +La capitale de Espagne est Madrid. +``` + +`items()` renvoie des **tuples** `(cle, valeur)` que l'on déballe directement dans la boucle : c'est la forme que tu utiliseras le plus. + +Les méthodes `keys()`, `values()` et `items()` renvoient des « vues » ; si tu as besoin d'une vraie liste, convertis avec `list()` : + +```python +capitales = {"France": "Paris", "Italie": "Rome"} +print(list(capitales.keys())) # → ['France', 'Italie'] +print(list(capitales.values())) # → ['Paris', 'Rome'] +``` + +#### Un grand classique : compter avec un dictionnaire + +Compter combien de fois apparaît chaque élément est un usage incontournable des dictionnaires. + +```python +votes = ["chat", "chien", "chat", "lapin", "chat", "chien"] +compteur = {} +for animal in votes: + if animal in compteur: + compteur[animal] += 1 + else: + compteur[animal] = 1 +print(compteur) # → {'chat': 3, 'chien': 2, 'lapin': 1} +``` + +Avec `get`, c'est encore plus court : + +```python +votes = ["chat", "chien", "chat", "lapin", "chat", "chien"] +compteur = {} +for animal in votes: + compteur[animal] = compteur.get(animal, 0) + 1 +print(compteur) # → {'chat': 3, 'chien': 2, 'lapin': 1} +``` + +> Le module `collections` propose un outil tout prêt pour ça, `Counter`. Tu le découvriras au [chapitre 09](09-modules-bibliotheque-standard.md). + +#### Dictionnaires imbriqués et listes de dictionnaires + +Les structures se combinent. Une **liste de dictionnaires** est la façon standard de représenter un tableau de données (chaque dictionnaire est une ligne) : + +```python +contacts = [ + {"nom": "Alice", "tel": "06 11 22 33 44", "ville": "Lyon"}, + {"nom": "Bob", "tel": "07 55 66 77 88", "ville": "Nantes"}, +] +for contact in contacts: + print(f"{contact['nom']} ({contact['ville']}) : {contact['tel']}") +``` + +```text +Alice (Lyon) : 06 11 22 33 44 +Bob (Nantes) : 07 55 66 77 88 +``` + +> Dans une f-string délimitée par des guillemets `"…"`, utilise des apostrophes `'…'` pour les clés, afin de ne pas fermer la chaîne par erreur. + +Et un **dictionnaire de listes** permet de regrouper plusieurs valeurs par clé : + +```python +notes_par_eleve = { + "Alice": [14, 16, 12], + "Bob": [9, 12], +} +notes_par_eleve["Bob"].append(15) +notes_par_eleve["Chloé"] = [18] +for eleve, notes in notes_par_eleve.items(): + print(f"{eleve} : {notes}, moyenne {sum(notes) / len(notes):.1f}") +``` + +```text +Alice : [14, 16, 12], moyenne 14.0 +Bob : [9, 12, 15], moyenne 12.0 +Chloé : [18], moyenne 18.0 +``` + +--- + +### 12. Les ensembles (set) : des éléments uniques + +Un **ensemble** est une collection **sans ordre** et **sans doublon**. On l'écrit entre accolades, mais sans paires `cle: valeur`. + +```python +couleurs = {"rouge", "vert", "bleu", "rouge"} +print(len(couleurs)) # → 3 +print("vert" in couleurs) # → True +``` + +Le doublon `"rouge"` a disparu automatiquement. Comme il n'y a pas d'ordre, **pas d'indice** : `couleurs[0]` provoque une erreur `TypeError`. L'ordre d'affichage d'un ensemble de chaînes peut même changer d'une exécution à l'autre ; pour un affichage stable, utilise `sorted(ensemble)`, qui renvoie une liste triée. + +#### Créer, ajouter, retirer + +```python +vide = set() # ATTENTION : {} crée un dictionnaire vide, pas un ensemble ! +vide.add(3) +vide.add(1) +vide.add(3) # déjà présent : ne fait rien +print(sorted(vide)) # → [1, 3] + +vide.discard(3) # retire 3 (aucune erreur s'il est absent) +vide.discard(42) # pas d'erreur +print(vide) # → {1} +``` + +`remove(x)` existe aussi, mais provoque une `KeyError` si `x` est absent ; `discard(x)` est plus tolérant. + +#### Supprimer les doublons d'une liste + +C'est l'usage le plus courant : + +```python +emails = ["a@x.fr", "b@x.fr", "a@x.fr", "c@x.fr", "b@x.fr"] +uniques = set(emails) +print(len(uniques)) # → 3 +print(sorted(uniques)) # → ['a@x.fr', 'b@x.fr', 'c@x.fr'] +``` + +#### Les opérations ensemblistes + +Comme en mathématiques, on peut combiner des ensembles : + +```python +club_foot = {"Alice", "Bob", "Chloé", "David"} +club_theatre = {"Chloé", "Emma", "Bob", "Farid"} + +print(sorted(club_foot | club_theatre)) # → ['Alice', 'Bob', 'Chloé', 'David', 'Emma', 'Farid'] +print(sorted(club_foot & club_theatre)) # → ['Bob', 'Chloé'] +print(sorted(club_foot - club_theatre)) # → ['Alice', 'David'] +print(sorted(club_foot ^ club_theatre)) # → ['Alice', 'David', 'Emma', 'Farid'] +``` + +| Opérateur | Nom | Résultat | +|---|---|---| +| `a \| b` | union | les éléments présents dans `a` **ou** `b` | +| `a & b` | intersection | les éléments présents dans `a` **et** `b` | +| `a - b` | différence | les éléments de `a` qui ne sont **pas** dans `b` | +| `a ^ b` | différence symétrique | les éléments présents dans **un seul** des deux | + +**Bonus :** tester `x in ensemble` est extrêmement rapide, même avec des millions d'éléments, alors que `x in liste` doit parcourir toute la liste. Quand tu dois faire beaucoup de tests d'appartenance, pense aux ensembles. + +--- + +### 13. Choisir la bonne structure + +| Besoin | Structure | +|---|---| +| Une suite d'éléments dans un ordre précis, qui va évoluer | **liste** | +| Quelques valeurs liées qui ne doivent pas changer (coordonnées, date) | **tuple** | +| Retrouver une valeur à partir d'un nom/identifiant | **dictionnaire** | +| Des éléments uniques, tester l'appartenance, comparer deux groupes | **ensemble** | + +Et le tableau des propriétés : + +| | Ordonné | Modifiable | Doublons | Accès | +|---|---|---|---|---| +| `list` | oui | oui | autorisés | par indice `l[0]` | +| `tuple` | oui | non | autorisés | par indice `t[0]` | +| `dict` | oui (ordre d'insertion) | oui | clés uniques | par clé `d["cle"]` | +| `set` | non | oui | interdits | aucun (seulement `in`) | + +Toutes ces structures fonctionnent avec `len()`, `in` et `for`. Et les conversions sont faciles : `list(...)`, `tuple(...)`, `set(...)`, `dict(...)`. + +```python +lettres = list("bonjour") +print(lettres) # → ['b', 'o', 'n', 'j', 'o', 'u', 'r'] +print(tuple([1, 2, 3])) # → (1, 2, 3) +print(sorted(set("bonjour"))) # → ['b', 'j', 'n', 'o', 'r', 'u'] +print(dict([("a", 1), ("b", 2)])) # → {'a': 1, 'b': 2} +``` + +--- + +## Pièges fréquents + +### Piège 1 — Croire que `b = a` copie une liste + +```python +originale = ["pain", "lait"] +sauvegarde = originale # pas une copie : un deuxième nom ! +originale.append("beurre") +print(sauvegarde) # → ['pain', 'lait', 'beurre'] +``` + +Aucun message d'erreur : c'est ce qui rend ce bug sournois. **Correction :** + +```python +originale = ["pain", "lait"] +sauvegarde = originale.copy() # vraie copie +originale.append("beurre") +print(sauvegarde) # → ['pain', 'lait'] +``` + +### Piège 2 — Modifier une liste pendant qu'on la parcourt + +On veut retirer toutes les notes inférieures à 10 : + +```python +notes = [8, 5, 12, 9, 15] +for note in notes: + if note < 10: + notes.remove(note) +print(notes) # → [5, 12, 15] +``` + +Le 5 est resté ! En retirant le 8, tous les éléments ont « glissé » d'une case vers la gauche, et la boucle a sauté le 5. **Correction :** construire une nouvelle liste (ou parcourir une copie). + +```python +notes = [8, 5, 12, 9, 15] +notes_gardees = [] +for note in notes: + if note >= 10: + notes_gardees.append(note) +notes = notes_gardees +print(notes) # → [12, 15] +``` + +Pour un dictionnaire, c'est encore plus strict : Python refuse carrément. + +```python +stock = {"pommes": 0, "poires": 4} +for fruit in stock: + if stock[fruit] == 0: + del stock[fruit] +``` + +```text +Traceback (most recent call last): + File "", line 4, in +RuntimeError: dictionary changed size during iteration +``` + +**Correction :** parcourir une copie des clés, `for fruit in list(stock):`. + +```python +stock = {"pommes": 0, "poires": 4} +for fruit in list(stock): + if stock[fruit] == 0: + del stock[fruit] +print(stock) # → {'poires': 4} +``` + +### Piège 3 — `liste = liste.sort()` + +```python +nombres = [3, 1, 2] +nombres = nombres.sort() +print(nombres) # → None +``` + +`sort()` trie la liste **sur place** et renvoie `None`. En réaffectant, tu as perdu ta liste. **Correction :** `nombres.sort()` tout seul, ou `nombres = sorted(nombres)`. + +### Piège 4 — Sortir des indices de la liste + +```python +nombres = [10, 20, 30] +for i in range(len(nombres) + 1): + print(nombres[i]) +``` + +```text +10 +20 +30 +Traceback (most recent call last): + File "", line 3, in +IndexError: list index out of range +``` + +Le dernier indice valide est `len(nombres) - 1`. **Correction :** `range(len(nombres))`, ou mieux, `for nombre in nombres:`. + +### Piège 5 — Accéder à une clé absente d'un dictionnaire + +```python +age = {"Alice": 30} +print(age["alice"]) # attention à la casse ! +``` + +```text +Traceback (most recent call last): + File "", line 2, in +KeyError: 'alice' +``` + +**Correction :** vérifier avec `if "alice" in age:` ou utiliser `age.get("alice", "inconnu")`. Et rappelle-toi que `"Alice"` et `"alice"` sont deux clés différentes. + +### Piège 6 — `{}` n'est pas un ensemble vide + +```python +deja_vus = {} +deja_vus.add("Paris") +``` + +```text +Traceback (most recent call last): + File "", line 2, in +AttributeError: 'dict' object has no attribute 'add' +``` + +**Correction :** `deja_vus = set()`. + +### Piège 7 — `append` au lieu de `extend` + +```python +liste = [1, 2] +liste.append([3, 4]) +print(liste) # → [1, 2, [3, 4]] +``` + +`append` ajoute **un** élément (ici, une liste entière). **Correction :** `liste.extend([3, 4])` donne `[1, 2, 3, 4]`. + +### Piège 8 — Créer une grille avec `*` + +```python +grille = [[0] * 3] * 3 +grille[0][0] = 1 +print(grille) # → [[1, 0, 0], [1, 0, 0], [1, 0, 0]] +``` + +Les trois lignes sont **la même** liste (trois étiquettes sur un seul objet, cf. section 9). **Correction :** créer chaque ligne séparément dans une boucle. + +```python +grille = [] +for _ in range(3): + grille.append([0] * 3) +grille[0][0] = 1 +print(grille) # → [[1, 0, 0], [0, 0, 0], [0, 0, 0]] +``` + +> Le nom de variable `_` est une convention qui signifie « je n'utilise pas cette variable ». + +--- + +## Exercices + +### Exercice 1 — Statistiques de notes ⭐ + +On donne `notes = [12, 8, 15, 19, 7, 11, 14]`. Affiche le nombre de notes, la plus petite, la plus grande, la moyenne arrondie à 2 décimales, et le nombre de notes supérieures ou égales à 10. + +Sortie attendue : + +```text +Nombre de notes : 7 +Minimum : 7 +Maximum : 19 +Moyenne : 12.29 +Notes >= 10 : 5 +``` + +### Exercice 2 — Manipulations de liste ⭐ + +Pars de `animaux = ["chat", "chien", "lapin"]` et, dans l'ordre : +1. ajoute `"hamster"` à la fin ; +2. insère `"poisson"` en première position ; +3. retire `"chien"` ; +4. affiche la liste, son premier et son dernier élément ; +5. affiche la liste triée par ordre alphabétique, puis la liste à l'envers (sans la trier). + +Sortie attendue : + +```text +['poisson', 'chat', 'lapin', 'hamster'] +Premier : poisson +Dernier : hamster +Triée : ['chat', 'hamster', 'lapin', 'poisson'] +À l'envers : ['hamster', 'lapin', 'chat', 'poisson'] +``` + +### Exercice 3 — Compter les mots ⭐⭐ + +On donne la phrase `phrase = "le chat mange le poisson et le chien mange le chat"`. La méthode `phrase.split()` découpe une chaîne en liste de mots (on la reverra au chapitre 06). Construis un dictionnaire qui associe chaque mot à son nombre d'apparitions, puis affiche chaque mot avec son compte, et enfin le mot le plus fréquent. + +Sortie attendue : + +```text +le : 4 +chat : 2 +mange : 2 +poisson : 1 +et : 1 +chien : 1 +Mot le plus fréquent : 'le' (4 fois) +``` + +### Exercice 4 — Petit annuaire ⭐⭐ + +On donne : + +```python +annuaire = {"Alice": "06 12 34 56 78", "Bob": "07 98 76 54 32", "Chloé": "06 55 44 33 22"} +``` + +Demande à l'utilisateur des prénoms en boucle (vide pour arrêter). Pour chaque prénom, affiche le numéro s'il existe, sinon propose de l'ajouter en demandant le numéro. À la fin, affiche le nombre de contacts. + +Exemple d'exécution : + +```text +Prénom (vide pour quitter) : Bob +Bob : 07 98 76 54 32 +Prénom (vide pour quitter) : David +David est inconnu. Son numéro : 06 00 00 00 00 +David ajouté. +Prénom (vide pour quitter) : +L'annuaire contient 4 contacts. +``` + +### Exercice 5 — Amis en commun ⭐⭐ + +On donne les listes d'amis de deux personnes (avec des doublons dus à une erreur de saisie) : + +```python +amis_lea = ["Tom", "Inès", "Hugo", "Tom", "Sarah", "Yanis"] +amis_noe = ["Sarah", "Lina", "Tom", "Hugo", "Lina"] +``` + +Affiche (toujours triés par ordre alphabétique) : les amis communs, les amis de Léa que Noé ne connaît pas, et tous les amis sans doublon, avec leur nombre. + +Sortie attendue : + +```text +Amis communs : ['Hugo', 'Sarah', 'Tom'] +Seulement chez Léa : ['Inès', 'Yanis'] +Tous (6) : ['Hugo', 'Inès', 'Lina', 'Sarah', 'Tom', 'Yanis'] +``` + +### Exercice 6 — Transposer une grille ⭐⭐ + +On donne une grille de 2 lignes et 3 colonnes : + +```python +grille = [ + [1, 2, 3], + [4, 5, 6], +] +``` + +Construis sa **transposée** (les lignes deviennent les colonnes), de 3 lignes et 2 colonnes, avec des boucles, puis affiche-la ligne par ligne. + +Sortie attendue : + +```text +[1, 4] +[2, 5] +[3, 6] +``` + +### Exercice 7 — Gestion de stock ⭐⭐⭐ + +Un magasin a ce stock et reçoit cette liste de commandes (tuples `(article, quantité)`) : + +```python +stock = {"stylo": 50, "cahier": 20, "gomme": 5, "règle": 0} +commandes = [("stylo", 10), ("gomme", 8), ("cahier", 20), ("règle", 1), ("trousse", 2)] +``` + +Traite les commandes dans l'ordre : +- si l'article n'existe pas, affiche `trousse : article inconnu` ; +- si le stock est insuffisant, affiche `gomme : stock insuffisant (5 disponible(s))` et ne retire rien ; +- sinon, retire la quantité et affiche `stylo : 10 expédié(s)`. + +Enfin, affiche le stock final, puis la liste triée des articles en rupture (quantité 0). + +Sortie attendue : + +```text +stylo : 10 expédié(s) +gomme : stock insuffisant (5 disponible(s)) +cahier : 20 expédié(s) +règle : stock insuffisant (0 disponible(s)) +trousse : article inconnu +Stock final : {'stylo': 40, 'cahier': 0, 'gomme': 5, 'règle': 0} +En rupture : ['cahier', 'règle'] +``` + +### Exercice 8 — Classement des joueurs ⭐⭐⭐ + +On donne les scores d'une partie : + +```python +scores = {"Léo": 1200, "Maya": 1850, "Sam": 900, "Inès": 1850, "Noah": 1500} +``` + +Affiche le classement du meilleur au moins bon score. En cas d'égalité, classe par ordre alphabétique **et donne le même rang** aux ex æquo. + +Indice : construis une liste de tuples `(score, nom)` et trie-la. Python compare les tuples élément par élément : d'abord le score, puis le nom en cas d'égalité. Comme on veut les scores décroissants mais les noms croissants, une astuce consiste à stocker `-score`. + +Sortie attendue : + +```text +1. Inès - 1850 pts +1. Maya - 1850 pts +3. Noah - 1500 pts +4. Léo - 1200 pts +5. Sam - 900 pts +``` + +--- + +## Mini-projet — Le gestionnaire de liste de courses + +### Cahier des charges + +Écris un programme interactif qui gère une liste de courses. Les articles sont stockés dans un **dictionnaire** `{article: quantite}`. Le programme affiche un menu en boucle : + +```text +=== LISTE DE COURSES === +1. Ajouter un article +2. Retirer un article +3. Afficher la liste +4. Vider la liste +5. Quitter +``` + +- **Ajouter** : demander le nom et la quantité. Si l'article existe déjà, **additionner** les quantités. Les noms sont enregistrés en minuscules (`saisie.lower()`) et sans espaces autour (`saisie.strip()`) pour que « Pain » et « pain » soient le même article. +- **Retirer** : demander le nom ; s'il existe, le supprimer, sinon afficher un message. +- **Afficher** : lister les articles **par ordre alphabétique**, avec leur quantité, et le nombre total d'articles. Si la liste est vide, le dire. +- **Vider** : tout supprimer. +- **Quitter** : afficher un message d'au revoir et arrêter. +- Un choix invalide affiche un message d'erreur sans planter. + +Exemple d'exécution : + +```text +Ton choix : 1 +Article : Pain +Quantité : 2 +2 x pain ajouté(s). +Ton choix : 1 +Article : pain +Quantité : 1 +1 x pain ajouté(s). +Ton choix : 3 +- pain : 3 +Total : 3 article(s) +``` + +### Étapes suggérées + +1. Crée le dictionnaire vide et une boucle `while True` qui affiche le menu et lit le choix. +2. Gère le choix `"5"` avec `break`, puis un `else` pour les choix invalides. +3. Implémente « Afficher » (avec `sorted(courses)` pour l'ordre alphabétique). +4. Implémente « Ajouter » avec `courses.get(article, 0) + quantite`. +5. Implémente « Retirer » et « Vider ». +6. Vérifie que la quantité saisie est bien un nombre avec `saisie.isdigit()` (renvoie `True` si la chaîne ne contient que des chiffres) avant d'appeler `int()`. + +### Pistes d'amélioration + +- Permettre de retirer seulement **une partie** de la quantité (et supprimer l'article si on tombe à 0). +- Ajouter un **prix unitaire** : dictionnaire de dictionnaires `{"pain": {"quantite": 2, "prix": 1.20}}` et afficher le total à payer. +- Classer les articles par **rayon** (`{"boulangerie": {...}, "frais": {...}}`). +- Utiliser `match/case` (chapitre 02) pour le menu. +- Plus tard : découper le programme en fonctions ([chapitre 05](05-fonctions.md)) et sauvegarder la liste dans un fichier JSON ([chapitre 07](07-fichiers-csv-json.md)). + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +```python +notes = [12, 8, 15, 19, 7, 11, 14] + +nb_admis = 0 +for note in notes: + if note >= 10: + nb_admis += 1 + +moyenne = sum(notes) / len(notes) + +print(f"Nombre de notes : {len(notes)}") +print(f"Minimum : {min(notes)}") +print(f"Maximum : {max(notes)}") +print(f"Moyenne : {moyenne:.2f}") +print(f"Notes >= 10 : {nb_admis}") +``` + +Sortie : + +```text +Nombre de notes : 7 +Minimum : 7 +Maximum : 19 +Moyenne : 12.29 +Notes >= 10 : 5 +``` + +`min`, `max`, `sum` et `len` font l'essentiel du travail. Le comptage des notes admises utilise le schéma « compteur » : une variable à 0 incrémentée dans la boucle. La f-string `{moyenne:.2f}` arrondit l'affichage à 2 décimales (86 / 7 = 12.2857…). + +
+ +
+Corrigé de l'exercice 2 + +```python +animaux = ["chat", "chien", "lapin"] + +animaux.append("hamster") +animaux.insert(0, "poisson") +animaux.remove("chien") + +print(animaux) +print(f"Premier : {animaux[0]}") +print(f"Dernier : {animaux[-1]}") +print(f"Triée : {sorted(animaux)}") +print(f"À l'envers : {animaux[::-1]}") +``` + +Sortie : + +```text +['poisson', 'chat', 'lapin', 'hamster'] +Premier : poisson +Dernier : hamster +Triée : ['chat', 'hamster', 'lapin', 'poisson'] +À l'envers : ['hamster', 'lapin', 'chat', 'poisson'] +``` + +On utilise `sorted()` et `[::-1]` plutôt que `sort()` et `reverse()` : ainsi, la liste `animaux` n'est pas modifiée par les affichages. L'indice `-1` donne le dernier élément quelle que soit la longueur. + +
+ +
+Corrigé de l'exercice 3 + +```python +phrase = "le chat mange le poisson et le chien mange le chat" + +compteur = {} +for mot in phrase.split(): + compteur[mot] = compteur.get(mot, 0) + 1 + +for mot, nombre in compteur.items(): + print(f"{mot} : {nombre}") + +# Recherche du maximum « à la main » +mot_max = "" +nombre_max = 0 +for mot, nombre in compteur.items(): + if nombre > nombre_max: + mot_max = mot + nombre_max = nombre + +print(f"Mot le plus fréquent : '{mot_max}' ({nombre_max} fois)") +``` + +Sortie : + +```text +le : 4 +chat : 2 +mange : 2 +poisson : 1 +et : 1 +chien : 1 +Mot le plus fréquent : 'le' (4 fois) +``` + +`compteur.get(mot, 0)` renvoie le compte actuel, ou 0 si le mot n'a jamais été vu. Le dictionnaire conserve l'ordre d'insertion, d'où l'ordre d'affichage. Pour le maximum, on garde en mémoire le meilleur trouvé jusqu'ici et on le remplace dès qu'on trouve mieux. + +
+ +
+Corrigé de l'exercice 4 + +```python +annuaire = {"Alice": "06 12 34 56 78", "Bob": "07 98 76 54 32", "Chloé": "06 55 44 33 22"} + +prenom = input("Prénom (vide pour quitter) : ") +while prenom != "": + if prenom in annuaire: + print(f"{prenom} : {annuaire[prenom]}") + else: + numero = input(f"{prenom} est inconnu. Son numéro : ") + annuaire[prenom] = numero + print(f"{prenom} ajouté.") + prenom = input("Prénom (vide pour quitter) : ") + +print(f"L'annuaire contient {len(annuaire)} contacts.") +``` + +Avec les saisies `Bob`, `David`, `06 00 00 00 00` puis une ligne vide : + +```text +Prénom (vide pour quitter) : Bob +Bob : 07 98 76 54 32 +Prénom (vide pour quitter) : David +David est inconnu. Son numéro : 06 00 00 00 00 +David ajouté. +Prénom (vide pour quitter) : +L'annuaire contient 4 contacts. +``` + +On teste d'abord `prenom in annuaire` pour éviter une `KeyError`. Ajouter un contact, c'est simplement affecter une nouvelle clé : `annuaire[prenom] = numero`. + +
+ +
+Corrigé de l'exercice 5 + +```python +amis_lea = ["Tom", "Inès", "Hugo", "Tom", "Sarah", "Yanis"] +amis_noe = ["Sarah", "Lina", "Tom", "Hugo", "Lina"] + +ens_lea = set(amis_lea) +ens_noe = set(amis_noe) + +communs = ens_lea & ens_noe +seulement_lea = ens_lea - ens_noe +tous = ens_lea | ens_noe + +print(f"Amis communs : {sorted(communs)}") +print(f"Seulement chez Léa : {sorted(seulement_lea)}") +print(f"Tous ({len(tous)}) : {sorted(tous)}") +``` + +Sortie : + +```text +Amis communs : ['Hugo', 'Sarah', 'Tom'] +Seulement chez Léa : ['Inès', 'Yanis'] +Tous (6) : ['Hugo', 'Inès', 'Lina', 'Sarah', 'Tom', 'Yanis'] +``` + +Convertir en `set` élimine les doublons d'un coup, puis les opérateurs `&`, `-` et `|` répondent directement aux questions. `sorted()` transforme l'ensemble en liste triée pour un affichage stable. + +
+ +
+Corrigé de l'exercice 6 + +```python +grille = [ + [1, 2, 3], + [4, 5, 6], +] + +nb_lignes = len(grille) +nb_colonnes = len(grille[0]) + +transposee = [] +for j in range(nb_colonnes): # chaque colonne de la grille... + nouvelle_ligne = [] + for i in range(nb_lignes): # ...devient une ligne de la transposée + nouvelle_ligne.append(grille[i][j]) + transposee.append(nouvelle_ligne) + +for ligne in transposee: + print(ligne) +``` + +Sortie : + +```text +[1, 4] +[2, 5] +[3, 6] +``` + +L'élément en ligne `i`, colonne `j` de la grille se retrouve en ligne `j`, colonne `i` de la transposée. La boucle extérieure parcourt donc les colonnes, et on crée une **nouvelle** liste à chaque tour (pas de piège 8 ici). + +
+ +
+Corrigé de l'exercice 7 + +```python +stock = {"stylo": 50, "cahier": 20, "gomme": 5, "règle": 0} +commandes = [("stylo", 10), ("gomme", 8), ("cahier", 20), ("règle", 1), ("trousse", 2)] + +for article, quantite in commandes: + if article not in stock: + print(f"{article} : article inconnu") + elif stock[article] < quantite: + print(f"{article} : stock insuffisant ({stock[article]} disponible(s))") + else: + stock[article] -= quantite + print(f"{article} : {quantite} expédié(s)") + +print(f"Stock final : {stock}") + +ruptures = [] +for article, quantite in stock.items(): + if quantite == 0: + ruptures.append(article) +ruptures.sort() +print(f"En rupture : {ruptures}") +``` + +Sortie : + +```text +stylo : 10 expédié(s) +gomme : stock insuffisant (5 disponible(s)) +cahier : 20 expédié(s) +règle : stock insuffisant (0 disponible(s)) +trousse : article inconnu +Stock final : {'stylo': 40, 'cahier': 0, 'gomme': 5, 'règle': 0} +En rupture : ['cahier', 'règle'] +``` + +Le déballage `for article, quantite in commandes` rend la boucle très lisible. L'ordre des tests compte : on vérifie d'abord que l'article existe (sinon `stock[article]` provoquerait une `KeyError`), puis que la quantité suffit. + +
+ +
+Corrigé de l'exercice 8 + +```python +scores = {"Léo": 1200, "Maya": 1850, "Sam": 900, "Inès": 1850, "Noah": 1500} + +# 1. Liste de tuples (-score, nom) : le tri croissant donnera +# les scores décroissants, puis les noms par ordre alphabétique. +classement = [] +for nom, score in scores.items(): + classement.append((-score, nom)) +classement.sort() + +# 2. Affichage avec gestion des ex æquo +rang = 0 +score_precedent = None +for position in range(len(classement)): + score_negatif, nom = classement[position] + score = -score_negatif + if score != score_precedent: + rang = position + 1 # nouveau score : le rang = la position réelle + print(f"{rang}. {nom} - {score} pts") + score_precedent = score +``` + +Sortie : + +```text +1. Inès - 1850 pts +1. Maya - 1850 pts +3. Noah - 1500 pts +4. Léo - 1200 pts +5. Sam - 900 pts +``` + +Les tuples se comparent comme des mots dans le dictionnaire : premier élément d'abord, puis le suivant en cas d'égalité. En stockant `-1850`, `-1500`, etc., le plus grand score devient le plus petit nombre et arrive en tête du tri croissant. Pour les ex æquo, le rang ne change que lorsque le score change, et il saute alors à la position réelle (d'où le « 3 » après deux « 1 », comme dans un vrai classement sportif). Au chapitre 06, tu verras une écriture plus directe avec `sorted(..., key=lambda ...)`. + +
+ +
+Corrigé du mini-projet + +```python +courses = {} + +while True: + print() + print("=== LISTE DE COURSES ===") + print("1. Ajouter un article") + print("2. Retirer un article") + print("3. Afficher la liste") + print("4. Vider la liste") + print("5. Quitter") + choix = input("Ton choix : ").strip() + + if choix == "1": + article = input("Article : ").strip().lower() + saisie = input("Quantité : ").strip() + if article == "": + print("Nom d'article vide, rien n'a été ajouté.") + elif not saisie.isdigit() or int(saisie) == 0: + print("La quantité doit être un nombre entier positif.") + else: + quantite = int(saisie) + courses[article] = courses.get(article, 0) + quantite + print(f"{quantite} x {article} ajouté(s).") + + elif choix == "2": + article = input("Article à retirer : ").strip().lower() + if article in courses: + del courses[article] + print(f"{article} retiré de la liste.") + else: + print(f"{article} n'est pas dans la liste.") + + elif choix == "3": + if len(courses) == 0: + print("La liste est vide.") + else: + total = 0 + for article in sorted(courses): + print(f"- {article} : {courses[article]}") + total += courses[article] + print(f"Total : {total} article(s)") + + elif choix == "4": + courses.clear() + print("Liste vidée.") + + elif choix == "5": + print("Bonnes courses, à bientôt !") + break + + else: + print("Choix invalide, tape un nombre entre 1 et 5.") +``` + +Exemple de session (saisies : `1`, `Pain`, `2`, `1`, `pain`, `1`, `1`, `Tomates`, `6`, `3`, `2`, `beurre`, `7`, `5`) : + +```text + +=== LISTE DE COURSES === +1. Ajouter un article +2. Retirer un article +3. Afficher la liste +4. Vider la liste +5. Quitter +Ton choix : 1 +Article : Pain +Quantité : 2 +2 x pain ajouté(s). + +=== LISTE DE COURSES === +1. Ajouter un article +2. Retirer un article +3. Afficher la liste +4. Vider la liste +5. Quitter +Ton choix : 1 +Article : pain +Quantité : 1 +1 x pain ajouté(s). + +=== LISTE DE COURSES === +1. Ajouter un article +2. Retirer un article +3. Afficher la liste +4. Vider la liste +5. Quitter +Ton choix : 1 +Article : Tomates +Quantité : 6 +6 x tomates ajouté(s). + +=== LISTE DE COURSES === +1. Ajouter un article +2. Retirer un article +3. Afficher la liste +4. Vider la liste +5. Quitter +Ton choix : 3 +- pain : 3 +- tomates : 6 +Total : 9 article(s) + +=== LISTE DE COURSES === +1. Ajouter un article +2. Retirer un article +3. Afficher la liste +4. Vider la liste +5. Quitter +Ton choix : 2 +Article à retirer : beurre +beurre n'est pas dans la liste. + +=== LISTE DE COURSES === +1. Ajouter un article +2. Retirer un article +3. Afficher la liste +4. Vider la liste +5. Quitter +Ton choix : 7 +Choix invalide, tape un nombre entre 1 et 5. + +=== LISTE DE COURSES === +1. Ajouter un article +2. Retirer un article +3. Afficher la liste +4. Vider la liste +5. Quitter +Ton choix : 5 +Bonnes courses, à bientôt ! +``` + +**Points clés de la solution :** + +- `while True` + `break` est le schéma classique d'un menu : on boucle « pour toujours » jusqu'à ce que l'utilisateur choisisse de quitter. +- `.strip().lower()` normalise les noms : « Pain », « pain » et « PAIN » désignent le même article. +- `courses.get(article, 0) + quantite` gère d'un coup le cas « nouvel article » et le cas « article déjà présent ». +- `saisie.isdigit()` est vérifié **avant** `int(saisie)` : si la saisie contient des lettres, `int()` provoquerait une erreur. Grâce à l'évaluation paresseuse de `or` (chapitre 02), `int(saisie)` n'est jamais appelé si `isdigit()` est faux. Au [chapitre 08](08-erreurs-et-debogage.md), tu verras une autre approche avec `try/except`. +- `sorted(courses)` renvoie les clés triées sans modifier le dictionnaire. + +
+ +--- + +## Récapitulatif + +- Une **liste** `[a, b, c]` est ordonnée et modifiable ; on accède aux éléments par **indice** à partir de 0 (`-1` pour le dernier). +- Méthodes essentielles : `append`, `insert`, `extend`, `remove`, `pop`, `sort`, `reverse`, `index`, `count`, `copy`, `clear`. +- Fonctions utiles : `len`, `min`, `max`, `sum`, `sorted`, et le test `in`. +- Le **slicing** `liste[debut:fin:pas]` extrait une portion (fin exclue). +- `b = a` ne copie pas : les deux noms désignent **la même** liste. Pour copier : `a.copy()`, `list(a)` ou `a[:]` (et `copy.deepcopy` pour les listes imbriquées). +- Ne modifie jamais une collection pendant que tu la parcours : construis-en une nouvelle. +- Un **tuple** `(a, b)` est immuable ; on le **déballe** avec `x, y = point`. Un tuple à un élément s'écrit `(x,)`. +- Un **dictionnaire** `{cle: valeur}` permet de retrouver une valeur par sa clé ; `get(cle, defaut)` évite les `KeyError`, `items()` permet de parcourir les paires. +- Un **ensemble** `{a, b}` ne contient pas de doublons ; il se crée vide avec `set()` et supporte `|`, `&`, `-`, `^`. + +### Checklist de validation + +- [ ] Je sais créer une liste, lire `liste[0]` et `liste[-1]`, et la modifier. +- [ ] Je connais la différence entre `append` et `extend`, entre `sort()` et `sorted()`. +- [ ] Je sais expliquer pourquoi `b = a` ne copie pas une liste, et comment faire une vraie copie. +- [ ] Je sais déballer un tuple et échanger deux variables en une ligne. +- [ ] Je sais compter des occurrences avec un dictionnaire et `get`. +- [ ] Je sais parcourir un dictionnaire avec `items()`. +- [ ] Je sais supprimer les doublons d'une liste et trouver les éléments communs à deux listes. +- [ ] J'ai terminé le mini-projet « liste de courses ». + +--- + +[← Chapitre précédent](03-boucles.md) | [Sommaire](README.md) | [Chapitre suivant →](05-fonctions.md) diff --git a/docs/presentations/Python/05-fonctions.md b/docs/presentations/Python/05-fonctions.md new file mode 100644 index 0000000..347d1bc --- /dev/null +++ b/docs/presentations/Python/05-fonctions.md @@ -0,0 +1,1630 @@ +# Chapitre 05 — Les fonctions + +> **Durée estimée :** 5 à 6 heures (cours, exercices et mini-projet) +> +> **Prérequis :** [Chapitre 04 — Les structures de données](04-structures-de-donnees.md) (listes, tuples, dictionnaires, ensembles) +> +> **Objectifs — à la fin de ce chapitre, tu sauras…** +> +> - définir une fonction avec `def` et l'appeler ; +> - transmettre des informations avec des **paramètres** et récupérer un résultat avec `return` ; +> - utiliser des valeurs par défaut, des arguments nommés, `*args` et `**kwargs` ; +> - comprendre la **portée** des variables (locale ou globale) ; +> - documenter une fonction avec une **docstring** ; +> - écrire une fonction **récursive** simple et découper un programme en fonctions. + +--- + +## Cours + +### 1. Pourquoi des fonctions ? + +Tu utilises déjà des fonctions depuis le début : `print()`, `len()`, `input()`, `sum()`… Ce sont des **morceaux de code prêts à l'emploi**, auxquels on donne des informations et qui font un travail. + +Imagine maintenant que, dans ton programme, tu doives afficher un joli cadre autour d'un titre à cinq endroits différents. Tu pourrais copier-coller cinq fois les mêmes lignes… mais le jour où tu veux changer le style du cadre, il faudra modifier les cinq copies (et tu en oublieras une). + +**Une fonction, c'est une recette de cuisine :** tu l'écris une fois, tu lui donnes un nom (« pâte à crêpes »), et ensuite il suffit de dire « fais une pâte à crêpes » pour qu'elle soit exécutée. Les ingrédients sont les **paramètres**, le plat obtenu est la **valeur de retour**. + +Les fonctions permettent de : + +- **ne pas se répéter** (principe *DRY* : *Don't Repeat Yourself*) ; +- **découper** un gros problème en petits problèmes simples ; +- **nommer** une action, ce qui rend le code lisible comme un texte ; +- **tester** chaque morceau séparément (on le fera au [chapitre 13](13-tests-pytest-bonnes-pratiques.md)). + +--- + +### 2. Définir et appeler une fonction + +On définit une fonction avec le mot-clé `def`, suivi de son nom, de parenthèses et de deux-points. Le corps de la fonction est **indenté**, comme pour un `if` ou un `for`. + +```python +def dire_bonjour(): + print("Bonjour !") + print("Bienvenue dans le cours Python.") +``` + +Si tu exécutes uniquement ce code, **rien ne s'affiche** ! On a seulement écrit la recette, on ne l'a pas encore cuisinée. Pour exécuter la fonction, il faut l'**appeler** en écrivant son nom suivi de parenthèses : + +```python +def dire_bonjour(): + print("Bonjour !") + print("Bienvenue dans le cours Python.") + + +dire_bonjour() +dire_bonjour() +``` + +```text +Bonjour ! +Bienvenue dans le cours Python. +Bonjour ! +Bienvenue dans le cours Python. +``` + +Quelques règles : + +- Le nom suit les mêmes règles que les variables : minuscules, mots séparés par `_`. Choisis de préférence un **verbe** qui décrit l'action : `calculer_moyenne`, `afficher_menu`, `demander_age`. +- La fonction doit être **définie avant d'être appelée** (plus haut dans le fichier). +- Par convention (PEP 8, voir le [chapitre 13](13-tests-pytest-bonnes-pratiques.md)), on laisse **deux lignes vides** avant et après une définition de fonction. + +Si tu appelles une fonction avant de l'avoir définie : + +```python +saluer() + +def saluer(): + print("Salut") +``` + +```text +Traceback (most recent call last): + File "", line 1, in +NameError: name 'saluer' is not defined +``` + +Python lit ton fichier de haut en bas : au moment de l'appel, il ne connaît pas encore `saluer`. + +--- + +### 3. Les paramètres : donner des informations à la fonction + +Une fonction devient vraiment utile quand on peut lui transmettre des données. On déclare des **paramètres** entre les parenthèses : ce sont des variables qui recevront les valeurs au moment de l'appel. + +```python +def saluer(prenom): + print(f"Bonjour {prenom} !") + + +saluer("Alice") +saluer("Bob") +``` + +```text +Bonjour Alice ! +Bonjour Bob ! +``` + +Vocabulaire : +- **paramètre** : le nom dans la définition (`prenom`) ; +- **argument** : la valeur effectivement transmise lors de l'appel (`"Alice"`). + +On peut avoir plusieurs paramètres, séparés par des virgules : + +```python +def afficher_produit(nom, prix, quantite): + total = prix * quantite + print(f"{quantite} x {nom} à {prix:.2f} € = {total:.2f} €") + + +afficher_produit("Croissant", 1.10, 4) +afficher_produit("Baguette", 1.25, 2) +``` + +```text +4 x Croissant à 1.10 € = 4.40 € +2 x Baguette à 1.25 € = 2.50 € +``` + +Les arguments sont associés aux paramètres **dans l'ordre** : le premier argument va dans le premier paramètre, etc. C'est pourquoi on les appelle des **arguments positionnels**. Si tu en donnes trop ou pas assez, Python proteste : + +```python +def saluer(prenom): + print(f"Bonjour {prenom} !") + + +saluer() +``` + +```text +Traceback (most recent call last): + File "", line 5, in +TypeError: saluer() missing 1 required positional argument: 'prenom' +``` + +--- + +### 4. `return` : renvoyer un résultat + +Jusqu'ici, nos fonctions **affichent** quelque chose. Mais souvent, on veut qu'une fonction **calcule** une valeur et nous la **donne** pour qu'on puisse s'en servir ensuite. C'est le rôle de `return`. + +```python +def carre(nombre): + return nombre * nombre + + +resultat = carre(5) +print(resultat) # → 25 +print(carre(3) + carre(4)) # → 25 +``` + +L'appel `carre(5)` est **remplacé** par la valeur renvoyée, exactement comme `len([1, 2, 3])` est remplacé par `3`. On peut donc l'utiliser dans un calcul, le stocker, le passer à une autre fonction… + +#### `print` ou `return` ? La grande confusion + +C'est LA confusion du débutant. Compare : + +```python +def double_affiche(x): + print(x * 2) + + +def double_renvoie(x): + return x * 2 + + +a = double_affiche(10) # affiche 20, mais... +b = double_renvoie(10) # n'affiche rien, mais... +print(a) # ... a vaut None ! +print(b) # ... b vaut bien 20 +``` + +```text +20 +None +20 +``` + +- `print` **montre** une valeur à l'humain qui regarde l'écran. Le programme, lui, ne peut rien en faire. +- `return` **donne** une valeur au programme, qui peut la réutiliser. + +**Analogie :** demander à un collègue de calculer un total. Avec `print`, il crie le résultat dans le couloir : tu l'entends, mais tu ne l'as pas sur papier. Avec `return`, il te tend une feuille avec le résultat : tu peux la garder et t'en servir. + +**Règle d'or :** une fonction qui **calcule** doit **renvoyer** son résultat ; c'est le code qui l'appelle qui décide de l'afficher ou non. + +#### Une fonction sans `return` renvoie `None` + +Si une fonction se termine sans rencontrer de `return`, elle renvoie automatiquement `None`, la valeur qui signifie « rien ». + +#### `return` arrête la fonction immédiatement + +Dès qu'un `return` est exécuté, la fonction s'arrête ; les lignes suivantes sont ignorées. On s'en sert souvent pour sortir tôt : + +```python +def est_majeur(age): + if age >= 18: + return True + return False + + +print(est_majeur(20)) # → True +print(est_majeur(15)) # → False +``` + +On peut d'ailleurs écrire plus directement `return age >= 18`, puisque la comparaison est déjà un booléen. + +```python +def mention(note): + if note >= 16: + return "Très bien" + if note >= 14: + return "Bien" + if note >= 12: + return "Assez bien" + if note >= 10: + return "Passable" + return "Insuffisant" + + +for note in [17, 14.5, 10, 8]: + print(note, "→", mention(note)) +``` + +```text +17 → Très bien +14.5 → Bien +10 → Passable +8 → Insuffisant +``` + +Pas besoin de `elif` ici : si la première condition est vraie, le `return` a déjà quitté la fonction. + +#### Renvoyer plusieurs valeurs + +Une fonction peut renvoyer plusieurs valeurs séparées par des virgules : elle renvoie en fait un **tuple**, qu'on déballe à l'arrivée (cf. [chapitre 04](04-structures-de-donnees.md)). + +```python +def min_max_moyenne(nombres): + moyenne = sum(nombres) / len(nombres) + return min(nombres), max(nombres), moyenne + + +petit, grand, moy = min_max_moyenne([12, 8, 15, 19]) +print(petit, grand, moy) # → 8 19 13.5 +``` + +--- + +### 5. Valeurs par défaut et arguments nommés + +#### Les valeurs par défaut + +On peut donner une **valeur par défaut** à un paramètre : s'il n'est pas fourni à l'appel, c'est elle qui sera utilisée. + +```python +def saluer(prenom, salutation="Bonjour"): + print(f"{salutation} {prenom} !") + + +saluer("Alice") # → Bonjour Alice ! +saluer("Bob", "Salut") # → Salut Bob ! +``` + +Tu connais déjà ce mécanisme : `print()` a un paramètre `end` qui vaut `"\n"` par défaut, et `sorted()` a `reverse=False`. + +Les paramètres avec valeur par défaut doivent être placés **après** ceux qui n'en ont pas. `def f(a=1, b):` provoque une `SyntaxError`. + +#### Les arguments nommés + +À l'appel, on peut préciser le **nom** du paramètre : `nom=valeur`. L'ordre n'a alors plus d'importance, et surtout, le code devient beaucoup plus clair. + +```python +def creer_compte(pseudo, age, pays="France", newsletter=False): + print(f"{pseudo}, {age} ans, {pays}, newsletter : {newsletter}") + + +creer_compte("alice42", 25) +creer_compte("bob_du_75", 31, newsletter=True) +creer_compte(age=19, pseudo="chloe", pays="Belgique") +``` + +```text +alice42, 25 ans, France, newsletter : False +bob_du_75, 31 ans, France, newsletter : True +chloe, 19 ans, Belgique, newsletter : False +``` + +Compare `creer_compte("bob_du_75", 31, "France", True)` et `creer_compte("bob_du_75", 31, newsletter=True)` : dans le second cas, on comprend immédiatement ce que signifie `True`. + +Les arguments positionnels doivent toujours venir **avant** les arguments nommés à l'appel. + +--- + +### 6. `*args` et `**kwargs` : un nombre variable d'arguments + +#### `*args` : autant d'arguments positionnels qu'on veut + +Comment `print()` peut-elle accepter 1, 2 ou 10 arguments ? Grâce à l'étoile `*`. Un paramètre précédé de `*` récupère **tous les arguments positionnels restants** dans un **tuple**. + +```python +def moyenne(*nombres): + print(f"Reçu : {nombres}") + if len(nombres) == 0: + return 0 + return sum(nombres) / len(nombres) + + +print(moyenne(12, 15)) +print(moyenne(8, 10, 12, 14)) +print(moyenne()) +``` + +```text +Reçu : (12, 15) +13.5 +Reçu : (8, 10, 12, 14) +11.0 +Reçu : () +0 +``` + +Le nom `args` est une simple convention (*arguments*) ; c'est l'étoile qui compte. Ici, `nombres` est plus parlant. + +#### `**kwargs` : autant d'arguments nommés qu'on veut + +Deux étoiles `**` récupèrent tous les **arguments nommés** non prévus dans un **dictionnaire** (*kwargs* = *keyword arguments*). + +```python +def fiche(nom, **infos): + print(f"--- {nom} ---") + for cle, valeur in infos.items(): + print(f"{cle} : {valeur}") + + +fiche("Alice", age=30, ville="Lyon", metier="développeuse") +fiche("Bob") +``` + +```text +--- Alice --- +age : 30 +ville : Lyon +metier : développeuse +--- Bob --- +``` + +#### L'étoile à l'appel : déballer une collection + +Inversement, à l'**appel**, `*` déballe une liste en arguments positionnels, et `**` déballe un dictionnaire en arguments nommés : + +```python +def addition(a, b, c): + return a + b + c + + +valeurs = [1, 2, 3] +print(addition(*valeurs)) # → 6 (équivaut à addition(1, 2, 3)) + +options = {"b": 20, "c": 30} +print(addition(10, **options)) # → 60 (équivaut à addition(10, b=20, c=30)) +``` + +L'ordre complet des paramètres dans une définition est : `def f(positionnels, avec_defaut=..., *args, **kwargs)`. Tu n'auras pas souvent besoin de tout combiner : `*args` et `**kwargs` sont surtout utiles pour écrire des fonctions très souples, et tu les croiseras beaucoup en lisant le code des bibliothèques. + +--- + +### 7. La portée des variables : locale ou globale + +Une variable créée **à l'intérieur** d'une fonction est **locale** : elle n'existe que pendant l'exécution de la fonction, et n'est pas visible à l'extérieur. + +```python +def calculer(): + resultat = 42 + print(f"Dans la fonction : {resultat}") + + +calculer() +print(resultat) +``` + +```text +Dans la fonction : 42 +Traceback (most recent call last): + File "", line 6, in +NameError: name 'resultat' is not defined +``` + +**Analogie :** une fonction est comme une pièce fermée avec un tableau blanc. Ce qu'on écrit sur le tableau à l'intérieur (les variables locales) est effacé en sortant. Pour faire sortir une information, il faut la passer par la porte : c'est `return`. + +Les paramètres sont eux aussi des variables locales. + +#### Lire une variable globale + +Une variable créée au niveau principal du fichier (hors de toute fonction) est **globale**. Une fonction peut la **lire** : + +```python +TAUX_TVA = 0.20 + + +def prix_ttc(prix_ht): + return prix_ht * (1 + TAUX_TVA) + + +print(prix_ttc(100)) # → 120.0 +``` + +Les constantes globales (valeurs qui ne changent jamais) s'écrivent par convention en **MAJUSCULES**. + +#### Une affectation crée une variable locale + +En revanche, si tu **affectes** une variable dans une fonction, Python crée une nouvelle variable locale, même si une globale du même nom existe : + +```python +compteur = 0 + + +def incrementer(): + compteur = 100 # variable LOCALE, sans rapport avec la globale + print(f"Dans la fonction : {compteur}") + + +incrementer() +print(f"Dehors : {compteur}") +``` + +```text +Dans la fonction : 100 +Dehors : 0 +``` + +Et si tu essaies de modifier la globale avec `+=`, c'est l'erreur : + +```python +compteur = 0 + + +def incrementer(): + compteur += 1 + + +incrementer() +``` + +```text +Traceback (most recent call last): + File "", line 8, in + File "", line 5, in incrementer +UnboundLocalError: cannot access local variable 'compteur' where it is not associated with a value +``` + +Python a vu une affectation (`+=`) : il considère donc `compteur` comme locale… mais elle n'a pas encore de valeur. + +#### Le mot-clé `global` (à éviter) + +Le mot-clé `global` permet de dire « je veux modifier la variable globale » : + +```python +compteur = 0 + + +def incrementer(): + global compteur + compteur += 1 + + +incrementer() +incrementer() +print(compteur) # → 2 +``` + +Ça marche, mais **c'est une mauvaise habitude** : n'importe quelle fonction peut modifier la variable, et on ne sait plus qui a fait quoi. La bonne pratique : **passer les données en paramètres et récupérer les résultats avec `return`**. + +```python +def incrementer(valeur): + return valeur + 1 + + +compteur = 0 +compteur = incrementer(compteur) +compteur = incrementer(compteur) +print(compteur) # → 2 +``` + +#### Les objets modifiables passés en paramètre + +Attention : si tu passes une **liste** ou un **dictionnaire** à une fonction, la fonction reçoit une étiquette vers **le même objet** (souviens-toi de la section « Références et copies » du chapitre 04). Si elle le modifie, la modification est visible à l'extérieur : + +```python +def ajouter_bonus(notes): + notes.append(20) + + +mes_notes = [12, 15] +ajouter_bonus(mes_notes) +print(mes_notes) # → [12, 15, 20] +``` + +Parfois c'est voulu (comme ici), parfois c'est un bug. Si la fonction ne doit pas toucher à l'original, elle doit travailler sur une copie ou construire une nouvelle liste qu'elle renvoie. + +--- + +### 8. Documenter : les docstrings + +Une **docstring** est une chaîne entre triples guillemets placée **juste sous la ligne `def`**. Elle explique ce que fait la fonction, ce qu'elle attend et ce qu'elle renvoie. + +```python +def convertir_temperature(celsius): + """Convertit une température de degrés Celsius en degrés Fahrenheit. + + Paramètre : + celsius : la température en °C (int ou float). + + Renvoie : + La température en °F (float). + """ + return celsius * 9 / 5 + 32 + + +print(convertir_temperature(100)) # → 212.0 +print(convertir_temperature(-40)) # → -40.0 +``` + +Contrairement à un commentaire `#`, la docstring est **attachée à la fonction** : la fonction `help()` l'affiche, et ton éditeur (VS Code) la montre quand tu survoles le nom de la fonction. + +```python +def carre(nombre): + """Renvoie le carré de nombre.""" + return nombre * nombre + + +help(carre) +``` + +```text +Help on function carre in module __main__: + +carre(nombre) + Renvoie le carré de nombre. + +``` + +Pour une fonction simple, une seule ligne suffit. Pour une fonction plus complexe, décris les paramètres et la valeur de retour. Une bonne docstring dit **ce que fait** la fonction, pas **comment** elle le fait. + +> **Bonus :** au [chapitre 11](11-poo-avancee-heritage-typage.md), tu verras les **annotations de type** (`def carre(nombre: float) -> float:`), qui complètent la documentation. + +--- + +### 9. Les fonctions sont des valeurs + +En Python, une fonction est un objet comme un autre : on peut la ranger dans une variable, dans une liste, ou la passer en argument à une autre fonction. Attention à la différence entre `carre` (la fonction elle-même) et `carre(3)` (le résultat de son appel). + +```python +def carre(x): + return x * x + + +def cube(x): + return x * x * x + + +def appliquer(fonction, valeurs): + resultats = [] + for valeur in valeurs: + resultats.append(fonction(valeur)) + return resultats + + +print(appliquer(carre, [1, 2, 3])) # → [1, 4, 9] +print(appliquer(cube, [1, 2, 3])) # → [1, 8, 27] +``` + +Tu as déjà utilisé ce principe sans le savoir avec `sorted(mots, key=len)` : on passe la fonction `len` (sans parenthèses) à `sorted`, qui l'appelle elle-même sur chaque mot. On ira plus loin avec les fonctions `lambda` au [chapitre 06](06-chaines-et-comprehensions.md). + +--- + +### 10. La récursivité : une fonction qui s'appelle elle-même + +Une fonction **récursive** est une fonction qui s'appelle elle-même. Cela paraît étrange, mais c'est très naturel pour certains problèmes. + +**Analogie :** les poupées russes. Pour ouvrir toutes les poupées, tu ouvres la première… et à l'intérieur, tu as le même problème, en plus petit. Tu t'arrêtes quand la poupée ne s'ouvre plus : c'est le **cas de base**. + +Exemple : la factorielle. `5! = 5 × 4 × 3 × 2 × 1`. On remarque que `5! = 5 × 4!`, et `4! = 4 × 3!`, etc. Et par définition `1! = 1` (et `0! = 1`). + +```python +def factorielle(n): + if n <= 1: # cas de base : on s'arrête + return 1 + return n * factorielle(n - 1) # appel récursif sur un problème plus petit + + +print(factorielle(5)) # → 120 +print(factorielle(10)) # → 3628800 +``` + +Voici ce qui se passe pour `factorielle(4)` : + +```text +factorielle(4) = 4 * factorielle(3) + factorielle(3) = 3 * factorielle(2) + factorielle(2) = 2 * factorielle(1) + factorielle(1) = 1 + factorielle(2) = 2 * 1 = 2 + factorielle(3) = 3 * 2 = 6 +factorielle(4) = 4 * 6 = 24 +``` + +Une fonction récursive a **toujours** deux ingrédients : + +1. un **cas de base** qui renvoie un résultat sans s'appeler (sinon elle tourne à l'infini) ; +2. un **appel récursif** sur un problème **plus petit**, qui se rapproche du cas de base. + +Autre exemple : un compte à rebours. + +```python +def compte_a_rebours(n): + if n == 0: + print("Décollage !") + return + print(n) + compte_a_rebours(n - 1) + + +compte_a_rebours(3) +``` + +```text +3 +2 +1 +Décollage ! +``` + +La récursivité est idéale pour les structures « emboîtées » : des listes de listes de listes, des dossiers contenant des sous-dossiers… Exemple : additionner tous les nombres d'une liste imbriquée à n'importe quelle profondeur. + +```python +def somme_imbriquee(elements): + total = 0 + for element in elements: + if type(element) == list: + total += somme_imbriquee(element) # une sous-liste : même problème, plus petit + else: + total += element + return total + + +print(somme_imbriquee([1, [2, 3], [4, [5, 6]], 7])) # → 28 +``` + +Si on oublie le cas de base, Python finit par abandonner : + +```python +def infini(n): + return infini(n + 1) + + +infini(0) +``` + +```text +Traceback (most recent call last): + File "", line 5, in + File "", line 2, in infini + File "", line 2, in infini + File "", line 2, in infini + [Previous line repeated 996 more times] +RecursionError: maximum recursion depth exceeded +``` + +Python limite la profondeur à environ 1000 appels. Pour la plupart des problèmes simples (somme, factorielle sur de grands nombres…), une boucle reste préférable ; garde la récursivité pour les structures naturellement emboîtées. + +--- + +### 11. Structurer un programme avec des fonctions + +Quand ton programme grossit, découpe-le en fonctions qui ont **chacune une seule responsabilité**, et regroupe le programme principal dans une fonction `main()`. + +```python +def demander_nombre(message): + """Demande un entier positif à l'utilisateur jusqu'à obtenir une saisie valide.""" + saisie = input(message) + while not saisie.isdigit(): + print("Merci d'entrer un nombre entier positif.") + saisie = input(message) + return int(saisie) + + +def calculer_prix(prix_unitaire, quantite, remise=0): + """Renvoie le prix total après application d'une remise en pourcentage.""" + total = prix_unitaire * quantite + return total * (1 - remise / 100) + + +def afficher_ticket(quantite, total): + """Affiche un petit ticket de caisse.""" + print("-" * 20) + print(f"Quantité : {quantite}") + print(f"Total : {total:.2f} €") + print("-" * 20) + + +def main(): + quantite = demander_nombre("Combien de places ? ") + remise = 10 if quantite >= 5 else 0 + total = calculer_prix(12.50, quantite, remise) + afficher_ticket(quantite, total) + + +main() +``` + +Avec les saisies `trois` puis `6` : + +```text +Combien de places ? trois +Merci d'entrer un nombre entier positif. +Combien de places ? 6 +-------------------- +Quantité : 6 +Total : 67.50 € +-------------------- +``` + +> `remise = 10 if quantite >= 5 else 0` est une **expression conditionnelle** (on dit aussi « ternaire ») : la valeur est `10` si la condition est vraie, `0` sinon. C'est un raccourci pour un `if/else` qui ne sert qu'à choisir une valeur. + +Avantages de ce découpage : + +- `main()` se lit comme un résumé du programme ; +- chaque fonction est courte et facile à vérifier ; +- `calculer_prix` ne fait ni `input` ni `print` : elle est réutilisable partout et facile à tester. + +> Au [chapitre 09](09-modules-bibliotheque-standard.md), tu verras qu'on remplace souvent le simple appel `main()` par `if __name__ == "__main__": main()`. + +--- + +## Pièges fréquents + +### Piège 1 — Oublier le `return` + +```python +def calculer_moyenne(notes): + moyenne = sum(notes) / len(notes) + + +resultat = calculer_moyenne([10, 14]) +print(resultat) # → None +``` + +La fonction calcule bien la moyenne… puis la jette. Sans `return`, elle renvoie `None`. Et ça casse souvent plus loin : + +```python +def calculer_moyenne(notes): + moyenne = sum(notes) / len(notes) + + +resultat = calculer_moyenne([10, 14]) +print(resultat + 1) +``` + +```text +Traceback (most recent call last): + File "", line 6, in +TypeError: unsupported operand type(s) for +: 'NoneType' and 'int' +``` + +**Correction :** ajouter `return moyenne`. Quand tu vois `NoneType` dans une erreur, pense tout de suite à un `return` oublié. + +### Piège 2 — Utiliser `print` au lieu de `return` + +```python +def ajouter_tva(prix): + print(prix * 1.2) + + +total = ajouter_tva(10) + ajouter_tva(20) +``` + +```text +12.0 +24.0 +Traceback (most recent call last): + File "", line 5, in +TypeError: unsupported operand type(s) for +: 'NoneType' and 'NoneType' +``` + +**Correction :** `return prix * 1.2`, et faire le `print` à l'extérieur, là où on a besoin d'afficher. + +### Piège 3 — L'argument par défaut mutable + +C'est un piège célèbre, même chez les développeurs expérimentés : + +```python +def ajouter_article(article, panier=[]): + panier.append(article) + return panier + + +print(ajouter_article("pain")) # → ['pain'] +print(ajouter_article("lait")) # → ['pain', 'lait'] +``` + +On s'attendait à `['lait']` ! La valeur par défaut `[]` est créée **une seule fois**, quand Python lit la ligne `def`. Tous les appels partagent donc **la même liste**, qui grossit d'appel en appel. + +**Correction :** utiliser `None` comme valeur par défaut, et créer la liste dans la fonction. + +```python +def ajouter_article(article, panier=None): + if panier is None: + panier = [] + panier.append(article) + return panier + + +print(ajouter_article("pain")) # → ['pain'] +print(ajouter_article("lait")) # → ['lait'] +``` + +Règle : **jamais de liste, de dictionnaire ou d'ensemble comme valeur par défaut**. + +### Piège 4 — Oublier les parenthèses à l'appel + +```python +def dire_bonjour(): + return "Bonjour" + + +message = dire_bonjour +print(message) +``` + +Sortie (l'adresse varie à chaque exécution) : + +```text + +``` + +Sans parenthèses, on ne **appelle** pas la fonction : on manipule la fonction elle-même. **Correction :** `message = dire_bonjour()`. + +### Piège 5 — Code placé après `return` + +```python +def diviser(a, b): + return a / b + print("Division effectuée") # jamais exécuté ! + + +print(diviser(10, 4)) # → 2.5 +``` + +Tout ce qui suit un `return` (dans le même bloc) n'est jamais exécuté. **Correction :** placer le `print` avant le `return`, ou le supprimer. + +### Piège 6 — Modifier une variable globale sans le savoir… ou sans le pouvoir + +Le code de la section 7 avec `compteur += 1` dans une fonction provoque `UnboundLocalError`. **Correction :** passer la valeur en paramètre et la renvoyer (`compteur = incrementer(compteur)`) plutôt que d'utiliser `global`. + +### Piège 7 — Un `return` mal indenté dans une boucle + +```python +def contient_negatif(nombres): + for nombre in nombres: + if nombre < 0: + return True + return False # MAUVAISE indentation : dans la boucle ! + + +print(contient_negatif([3, 5, -2])) # → False +``` + +La fonction renvoie `False` dès le premier élément positif, sans regarder les autres. **Correction :** le `return False` doit être **après** la boucle (au même niveau que le `for`), pour n'être atteint que si aucun négatif n'a été trouvé. + +```python +def contient_negatif(nombres): + for nombre in nombres: + if nombre < 0: + return True + return False + + +print(contient_negatif([3, 5, -2])) # → True +``` + +--- + +## Exercices + +### Exercice 1 — Fonctions simples ⭐ + +Écris : +- `aire_rectangle(longueur, largeur)` qui renvoie l'aire ; +- `est_pair(n)` qui renvoie `True` si `n` est pair, `False` sinon ; +- `plus_grand(a, b, c)` qui renvoie le plus grand des trois nombres **sans utiliser `max`**. + +Puis teste-les : + +```python +print(aire_rectangle(4, 2.5)) # 10.0 +print(est_pair(7)) # False +print(est_pair(10)) # True +print(plus_grand(3, 9, 5)) # 9 +``` + +### Exercice 2 — Convertisseur avec valeur par défaut ⭐ + +Écris une fonction `convertir(montant, taux=1.08, symbole="$")` qui renvoie une chaîne affichant le montant converti avec 2 décimales et le symbole. + +```python +print(convertir(100)) # 108.00 $ +print(convertir(50, 0.85, "£")) # 42.50 £ +print(convertir(1000, symbole="CHF", taux=0.94)) # 940.00 CHF +``` + +### Exercice 3 — Statistiques sur une liste ⭐⭐ + +Écris une fonction `statistiques(nombres)` qui renvoie un **dictionnaire** avec les clés `"min"`, `"max"`, `"moyenne"` (arrondie à 2 décimales avec `round(valeur, 2)`) et `"mediane"`. La médiane est la valeur centrale de la liste triée ; si la liste a un nombre pair d'éléments, c'est la moyenne des deux valeurs centrales. La liste d'origine ne doit **pas** être modifiée. + +```python +notes = [12, 5, 18, 9, 14, 11] +print(statistiques(notes)) +# {'min': 5, 'max': 18, 'moyenne': 11.5, 'mediane': 11.5} +print(statistiques([3, 1, 2])) +# {'min': 1, 'max': 3, 'moyenne': 2.0, 'mediane': 2} +print(notes) +# [12, 5, 18, 9, 14, 11] +``` + +### Exercice 4 — Validateur de mot de passe ⭐⭐ + +Écris une fonction `verifier_mot_de_passe(mdp)` qui renvoie une **liste de problèmes** (liste vide si tout va bien). Règles : au moins 8 caractères, au moins un chiffre, au moins une majuscule. Les méthodes `caractere.isdigit()` et `caractere.isupper()` te seront utiles. + +Écris ensuite une fonction `afficher_verdict(mdp)` qui utilise la première pour afficher le résultat. + +```python +afficher_verdict("abc") +afficher_verdict("motdepasse1") +afficher_verdict("MotDePasse2024") +``` + +```text +'abc' refusé : + - moins de 8 caractères + - aucun chiffre + - aucune majuscule +'motdepasse1' refusé : + - aucune majuscule +'MotDePasse2024' accepté. +``` + +### Exercice 5 — Somme flexible ⭐⭐ + +Écris une fonction `somme_ponderee(*valeurs, **options)` qui additionne toutes les valeurs. L'option `coef` (défaut 1) multiplie le total, et l'option `arrondi` (défaut 2) indique le nombre de décimales du résultat (avec `round`). + +```python +print(somme_ponderee(1, 2, 3)) # 6 +print(somme_ponderee(1.5, 2.25, coef=2)) # 7.5 +print(somme_ponderee(10, 3.333, coef=1.5, arrondi=1)) # 20.0 +print(somme_ponderee()) # 0 +``` + +### Exercice 6 — Puissance et palindrome récursifs ⭐⭐ + +Écris deux fonctions **récursives** (sans boucle) : +- `puissance(base, exposant)` qui calcule `base ** exposant` pour un exposant entier positif ou nul, en utilisant `base ** n = base × base ** (n-1)` et `base ** 0 = 1` ; +- `est_palindrome(mot)` qui renvoie `True` si le mot se lit pareil dans les deux sens. Indice : un mot de 0 ou 1 lettre est un palindrome ; sinon, il faut que la première et la dernière lettre soient égales **et** que `mot[1:-1]` (le mot sans ses extrémités) soit un palindrome. + +```python +print(puissance(2, 10)) # 1024 +print(puissance(5, 0)) # 1 +print(est_palindrome("radar")) # True +print(est_palindrome("python")) # False +print(est_palindrome("ressasser")) # True +``` + +### Exercice 7 — Gestion de bibliothèque ⭐⭐⭐ + +On représente une bibliothèque par une liste de dictionnaires : + +```python +livres = [ + {"titre": "Le Petit Prince", "auteur": "Saint-Exupéry", "annee": 1943, "dispo": True}, + {"titre": "1984", "auteur": "Orwell", "annee": 1949, "dispo": False}, + {"titre": "Vol de nuit", "auteur": "Saint-Exupéry", "annee": 1931, "dispo": True}, + {"titre": "La Ferme des animaux", "auteur": "Orwell", "annee": 1945, "dispo": True}, +] +``` + +Écris les fonctions suivantes (aucune ne doit utiliser `print`, sauf la dernière) : +- `livres_de(livres, auteur)` : renvoie la liste des **titres** de cet auteur ; +- `livres_disponibles(livres)` : renvoie la liste des titres disponibles ; +- `emprunter(livres, titre)` : si le livre existe et est disponible, le marque indisponible et renvoie `True` ; sinon renvoie `False` ; +- `plus_ancien(livres)` : renvoie le dictionnaire du livre le plus ancien ; +- `afficher(livres)` : affiche chaque livre au format `[x] Titre (Auteur, année)` ou `[ ]` s'il est emprunté. + +```python +print(livres_de(livres, "Orwell")) +print(emprunter(livres, "Vol de nuit")) +print(emprunter(livres, "Vol de nuit")) +print(emprunter(livres, "Dune")) +print(livres_disponibles(livres)) +print(plus_ancien(livres)["titre"]) +afficher(livres) +``` + +```text +['1984', 'La Ferme des animaux'] +True +False +False +['Le Petit Prince', 'La Ferme des animaux'] +Vol de nuit +[x] Le Petit Prince (Saint-Exupéry, 1943) +[ ] 1984 (Orwell, 1949) +[ ] Vol de nuit (Saint-Exupéry, 1931) +[x] La Ferme des animaux (Orwell, 1945) +``` + +### Exercice 8 — Aplatir une liste ⭐⭐⭐ + +Écris une fonction récursive `aplatir(elements)` qui transforme une liste imbriquée à n'importe quelle profondeur en une liste « plate », en conservant l'ordre. + +```python +print(aplatir([1, [2, 3], [4, [5, [6, 7]]], 8])) +# [1, 2, 3, 4, 5, 6, 7, 8] +print(aplatir([[["a"]], "b", []])) +# ['a', 'b'] +``` + +--- + +## Mini-projet — Le jeu du pendu, structuré en fonctions + +### Cahier des charges + +Programme le célèbre jeu du pendu : + +- l'ordinateur choisit un mot secret dans une liste ; +- le joueur propose une lettre à chaque tour ; +- on affiche le mot avec les lettres trouvées et des `_` pour les autres (ex. `P _ T H _ N`) ; +- le joueur a droit à **6 erreurs** ; +- une lettre déjà proposée n'est pas comptée comme une erreur, mais on prévient le joueur ; +- une saisie invalide (vide, plusieurs caractères, pas une lettre) est refusée ; +- la partie se termine par une victoire ou une défaite, en révélant le mot. + +**Contrainte principale :** le programme doit être découpé en fonctions courtes, chacune avec une docstring. Le programme principal se résume à un appel à `main()`. + +Pour choisir un mot au hasard, on utilise le module `random` (présenté en détail au [chapitre 09](09-modules-bibliotheque-standard.md)) : + +```python +import random + +mot = random.choice(["python", "fonction", "variable"]) +print(mot in ["python", "fonction", "variable"]) # → True +``` + +### Étapes suggérées + +1. `choisir_mot(mots)` : renvoie un mot au hasard, en majuscules. +2. `masquer(mot, lettres_trouvees)` : renvoie la chaîne `P _ T H _ N` (indice : construis une liste de caractères puis assemble-la avec `" ".join(liste)`, qu'on verra en détail au chapitre 06). +3. `demander_lettre(deja_proposees)` : boucle jusqu'à obtenir une lettre valide (`isalpha()` et longueur 1) et pas déjà proposée ; renvoie la lettre en majuscule. +4. `mot_complet(mot, lettres_trouvees)` : renvoie `True` si toutes les lettres du mot ont été trouvées. +5. `afficher_etat(...)` : affiche le mot masqué, les erreurs restantes et les lettres déjà proposées. +6. `jouer_partie(mot)` : la boucle de jeu ; renvoie `True` en cas de victoire. +7. `main()` : choisit le mot, lance la partie, affiche le message final. + +Astuce de test : pour tester ton programme sans hasard, appelle directement `jouer_partie("PYTHON")`. + +### Pistes d'amélioration + +- Dessiner le pendu en ASCII art (une liste de 7 dessins, indexée par le nombre d'erreurs). +- Proposer de rejouer à la fin et tenir un score (victoires / défaites). +- Gérer les accents : considérer que `E` trouve aussi `É`, `È`, `Ê`. +- Choisir un niveau de difficulté (longueur du mot, nombre d'erreurs autorisées). +- Lire la liste des mots depuis un fichier texte ([chapitre 07](07-fichiers-csv-json.md)). + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +```python +def aire_rectangle(longueur, largeur): + """Renvoie l'aire d'un rectangle.""" + return longueur * largeur + + +def est_pair(n): + """Renvoie True si n est pair.""" + return n % 2 == 0 + + +def plus_grand(a, b, c): + """Renvoie le plus grand des trois nombres.""" + maximum = a + if b > maximum: + maximum = b + if c > maximum: + maximum = c + return maximum + + +print(aire_rectangle(4, 2.5)) +print(est_pair(7)) +print(est_pair(10)) +print(plus_grand(3, 9, 5)) +``` + +Sortie : + +```text +10.0 +False +True +9 +``` + +`est_pair` renvoie directement le résultat de la comparaison `n % 2 == 0`, qui est déjà un booléen : inutile d'écrire `if ...: return True else: return False`. Pour `plus_grand`, on part du principe que `a` est le plus grand et on corrige si on trouve mieux. + +
+ +
+Corrigé de l'exercice 2 + +```python +def convertir(montant, taux=1.08, symbole="$"): + """Convertit un montant en euros dans une autre devise et renvoie une chaîne formatée.""" + converti = montant * taux + return f"{converti:.2f} {symbole}" + + +print(convertir(100)) +print(convertir(50, 0.85, "£")) +print(convertir(1000, symbole="CHF", taux=0.94)) +``` + +Sortie : + +```text +108.00 $ +42.50 £ +940.00 CHF +``` + +Le troisième appel montre l'intérêt des arguments nommés : l'ordre n'importe plus. La fonction **renvoie** la chaîne au lieu de l'afficher : on pourrait ainsi l'écrire dans un fichier ou la combiner avec d'autres textes. + +
+ +
+Corrigé de l'exercice 3 + +```python +def mediane(nombres): + """Renvoie la médiane d'une liste non vide de nombres.""" + tries = sorted(nombres) # copie triée : l'original n'est pas touché + n = len(tries) + milieu = n // 2 + if n % 2 == 1: + return tries[milieu] + return (tries[milieu - 1] + tries[milieu]) / 2 + + +def statistiques(nombres): + """Renvoie un dictionnaire avec le min, le max, la moyenne et la médiane.""" + return { + "min": min(nombres), + "max": max(nombres), + "moyenne": round(sum(nombres) / len(nombres), 2), + "mediane": mediane(nombres), + } + + +notes = [12, 5, 18, 9, 14, 11] +print(statistiques(notes)) +print(statistiques([3, 1, 2])) +print(notes) +``` + +Sortie : + +```text +{'min': 5, 'max': 18, 'moyenne': 11.5, 'mediane': 11.5} +{'min': 1, 'max': 3, 'moyenne': 2.0, 'mediane': 2} +[12, 5, 18, 9, 14, 11] +``` + +On a extrait le calcul de la médiane dans sa propre fonction : chaque fonction fait une seule chose. `sorted()` (et non `.sort()`) garantit que la liste de l'appelant n'est pas modifiée. Pour 6 éléments triés `[5, 9, 11, 12, 14, 18]`, `milieu` vaut 3 : on fait la moyenne de `tries[2]` (11) et `tries[3]` (12). + +
+ +
+Corrigé de l'exercice 4 + +```python +def verifier_mot_de_passe(mdp): + """Renvoie la liste des règles non respectées par mdp (vide si tout va bien).""" + problemes = [] + if len(mdp) < 8: + problemes.append("moins de 8 caractères") + + a_chiffre = False + a_majuscule = False + for caractere in mdp: + if caractere.isdigit(): + a_chiffre = True + if caractere.isupper(): + a_majuscule = True + + if not a_chiffre: + problemes.append("aucun chiffre") + if not a_majuscule: + problemes.append("aucune majuscule") + return problemes + + +def afficher_verdict(mdp): + """Affiche si le mot de passe est accepté, et sinon pourquoi.""" + problemes = verifier_mot_de_passe(mdp) + if len(problemes) == 0: + print(f"'{mdp}' accepté.") + else: + print(f"'{mdp}' refusé :") + for probleme in problemes: + print(f" - {probleme}") + + +afficher_verdict("abc") +afficher_verdict("motdepasse1") +afficher_verdict("MotDePasse2024") +``` + +Sortie : + +```text +'abc' refusé : + - moins de 8 caractères + - aucun chiffre + - aucune majuscule +'motdepasse1' refusé : + - aucune majuscule +'MotDePasse2024' accepté. +``` + +La séparation est importante : `verifier_mot_de_passe` **calcule** (et pourrait servir dans une application web), `afficher_verdict` **présente**. Les booléens `a_chiffre` et `a_majuscule` sont des « drapeaux » qui passent à `True` dès qu'on trouve un caractère correspondant. + +
+ +
+Corrigé de l'exercice 5 + +```python +def somme_ponderee(*valeurs, **options): + """Additionne les valeurs, multiplie par l'option coef et arrondit.""" + coef = options.get("coef", 1) + arrondi = options.get("arrondi", 2) + total = sum(valeurs) * coef + return round(total, arrondi) + + +print(somme_ponderee(1, 2, 3)) +print(somme_ponderee(1.5, 2.25, coef=2)) +print(somme_ponderee(10, 3.333, coef=1.5, arrondi=1)) +print(somme_ponderee()) +``` + +Sortie : + +```text +6 +7.5 +20.0 +0 +``` + +`valeurs` est un tuple (on peut l'envoyer directement à `sum`), `options` est un dictionnaire : `get` avec une valeur par défaut est donc parfait. Note que `round` appliqué à un entier renvoie un entier (`6`), et appliqué à un float renvoie un float. Dans un vrai programme, on écrirait plutôt `def somme_ponderee(*valeurs, coef=1, arrondi=2)` : des paramètres nommés explicites sont plus clairs que `**options`, mais l'exercice sert à manipuler `**kwargs`. + +
+ +
+Corrigé de l'exercice 6 + +```python +def puissance(base, exposant): + """Calcule base ** exposant récursivement (exposant entier >= 0).""" + if exposant == 0: # cas de base + return 1 + return base * puissance(base, exposant - 1) # problème plus petit + + +def est_palindrome(mot): + """Renvoie True si mot se lit de la même façon dans les deux sens.""" + if len(mot) <= 1: # cas de base + return True + if mot[0] != mot[-1]: # extrémités différentes : perdu + return False + return est_palindrome(mot[1:-1]) # on retire les extrémités et on recommence + + +print(puissance(2, 10)) +print(puissance(5, 0)) +print(est_palindrome("radar")) +print(est_palindrome("python")) +print(est_palindrome("ressasser")) +``` + +Sortie : + +```text +1024 +1 +True +False +True +``` + +Pour `"radar"` : `r == r`, on teste `"ada"` ; `a == a`, on teste `"d"` ; une seule lettre, c'est le cas de base, `True`. Chaque appel travaille sur un mot plus court de 2 lettres : on se rapproche forcément du cas de base. + +
+ +
+Corrigé de l'exercice 7 + +```python +def livres_de(livres, auteur): + """Renvoie la liste des titres écrits par auteur.""" + titres = [] + for livre in livres: + if livre["auteur"] == auteur: + titres.append(livre["titre"]) + return titres + + +def livres_disponibles(livres): + """Renvoie la liste des titres disponibles.""" + titres = [] + for livre in livres: + if livre["dispo"]: + titres.append(livre["titre"]) + return titres + + +def emprunter(livres, titre): + """Marque le livre comme emprunté. Renvoie True si l'emprunt a réussi.""" + for livre in livres: + if livre["titre"] == titre: + if livre["dispo"]: + livre["dispo"] = False + return True + return False # trouvé mais déjà emprunté + return False # pas trouvé du tout + + +def plus_ancien(livres): + """Renvoie le dictionnaire du livre le plus ancien.""" + ancien = livres[0] + for livre in livres: + if livre["annee"] < ancien["annee"]: + ancien = livre + return ancien + + +def afficher(livres): + """Affiche la liste des livres avec leur disponibilité.""" + for livre in livres: + case = "x" if livre["dispo"] else " " + print(f"[{case}] {livre['titre']} ({livre['auteur']}, {livre['annee']})") + + +livres = [ + {"titre": "Le Petit Prince", "auteur": "Saint-Exupéry", "annee": 1943, "dispo": True}, + {"titre": "1984", "auteur": "Orwell", "annee": 1949, "dispo": False}, + {"titre": "Vol de nuit", "auteur": "Saint-Exupéry", "annee": 1931, "dispo": True}, + {"titre": "La Ferme des animaux", "auteur": "Orwell", "annee": 1945, "dispo": True}, +] + +print(livres_de(livres, "Orwell")) +print(emprunter(livres, "Vol de nuit")) +print(emprunter(livres, "Vol de nuit")) +print(emprunter(livres, "Dune")) +print(livres_disponibles(livres)) +print(plus_ancien(livres)["titre"]) +afficher(livres) +``` + +Sortie : + +```text +['1984', 'La Ferme des animaux'] +True +False +False +['Le Petit Prince', 'La Ferme des animaux'] +Vol de nuit +[x] Le Petit Prince (Saint-Exupéry, 1943) +[ ] 1984 (Orwell, 1949) +[ ] Vol de nuit (Saint-Exupéry, 1931) +[x] La Ferme des animaux (Orwell, 1945) +``` + +`emprunter` **modifie** le dictionnaire reçu : comme la liste est passée par référence, la modification est visible après l'appel (c'est voulu ici). Note les deux `return False` : l'un quand le livre est trouvé mais indisponible, l'autre après la boucle quand il n'a pas été trouvé. On pourrait renvoyer des messages différents pour distinguer les deux cas. + +
+ +
+Corrigé de l'exercice 8 + +```python +def aplatir(elements): + """Transforme une liste imbriquée en liste à un seul niveau.""" + resultat = [] + for element in elements: + if type(element) == list: + resultat.extend(aplatir(element)) # on aplatit la sous-liste, puis on ajoute ses éléments + else: + resultat.append(element) + return resultat + + +print(aplatir([1, [2, 3], [4, [5, [6, 7]]], 8])) +print(aplatir([[["a"]], "b", []])) +``` + +Sortie : + +```text +[1, 2, 3, 4, 5, 6, 7, 8] +['a', 'b'] +``` + +Le cas de base est implicite : un élément qui n'est pas une liste est simplement ajouté, et une liste vide ne déclenche aucun tour de boucle. Note l'usage d'`extend` (ajouter **les éléments** de la liste aplatie) et non d'`append` (qui ajouterait la liste entière, cf. piège 7 du chapitre 04). + +
+ +
+Corrigé du mini-projet + +```python +import random + +MOTS = ["python", "fonction", "variable", "boucle", "dictionnaire", "liste"] +ERREURS_MAX = 6 + + +def choisir_mot(mots): + """Renvoie un mot choisi au hasard, en majuscules.""" + return random.choice(mots).upper() + + +def masquer(mot, lettres_trouvees): + """Renvoie le mot avec des _ à la place des lettres non trouvées.""" + affichage = [] + for lettre in mot: + if lettre in lettres_trouvees: + affichage.append(lettre) + else: + affichage.append("_") + return " ".join(affichage) + + +def mot_complet(mot, lettres_trouvees): + """Renvoie True si toutes les lettres du mot ont été trouvées.""" + for lettre in mot: + if lettre not in lettres_trouvees: + return False + return True + + +def demander_lettre(deja_proposees): + """Demande une lettre valide et pas encore proposée ; la renvoie en majuscule.""" + while True: + saisie = input("Ta lettre : ").strip().upper() + if len(saisie) != 1 or not saisie.isalpha(): + print("Tape une seule lettre.") + elif saisie in deja_proposees: + print(f"Tu as déjà proposé {saisie}.") + else: + return saisie + + +def afficher_etat(mot, lettres_trouvees, erreurs, deja_proposees): + """Affiche le mot masqué, les erreurs restantes et les lettres déjà jouées.""" + print() + print(masquer(mot, lettres_trouvees)) + print(f"Erreurs restantes : {ERREURS_MAX - erreurs}") + if deja_proposees: + print(f"Déjà proposées : {' '.join(sorted(deja_proposees))}") + + +def jouer_partie(mot): + """Déroule une partie complète. Renvoie True si le joueur gagne.""" + lettres_trouvees = set() + deja_proposees = set() + erreurs = 0 + + while erreurs < ERREURS_MAX: + afficher_etat(mot, lettres_trouvees, erreurs, deja_proposees) + lettre = demander_lettre(deja_proposees) + deja_proposees.add(lettre) + + if lettre in mot: + lettres_trouvees.add(lettre) + print(f"Bien joué, {lettre} est dans le mot !") + if mot_complet(mot, lettres_trouvees): + return True + else: + erreurs += 1 + print(f"Raté, pas de {lettre}.") + + return False + + +def main(): + """Point d'entrée du jeu.""" + print("=== JEU DU PENDU ===") + mot = choisir_mot(MOTS) + if jouer_partie(mot): + print(f"\nBravo ! Tu as trouvé le mot {mot}.") + else: + print(f"\nPerdu... Le mot était {mot}.") + + +main() +``` + +Pour vérifier le déroulement sans hasard, voici une partie avec le mot fixé (on remplace `main()` par l'appel direct ci-dessous) et les saisies `e`, `p`, `ab`, `y`, `p`, `t`, `h`, `o`, `n` : + +```python +# (à placer après les définitions précédentes, à la place de main()) +if jouer_partie("PYTHON"): + print("\nBravo !") +``` + +```text + +_ _ _ _ _ _ +Erreurs restantes : 6 +Ta lettre : e +Raté, pas de E. + +_ _ _ _ _ _ +Erreurs restantes : 5 +Déjà proposées : E +Ta lettre : p +Bien joué, P est dans le mot ! + +P _ _ _ _ _ +Erreurs restantes : 5 +Déjà proposées : E P +Ta lettre : ab +Tape une seule lettre. +Ta lettre : y +Bien joué, Y est dans le mot ! + +P Y _ _ _ _ +Erreurs restantes : 5 +Déjà proposées : E P Y +Ta lettre : p +Tu as déjà proposé P. +Ta lettre : t +Bien joué, T est dans le mot ! + +P Y T _ _ _ +Erreurs restantes : 5 +Déjà proposées : E P T Y +Ta lettre : h +Bien joué, H est dans le mot ! + +P Y T H _ _ +Erreurs restantes : 5 +Déjà proposées : E H P T Y +Ta lettre : o +Bien joué, O est dans le mot ! + +P Y T H O _ +Erreurs restantes : 5 +Déjà proposées : E H O P T Y +Ta lettre : n +Bien joué, N est dans le mot ! + +Bravo ! +``` + +**Points clés de la solution :** + +- Chaque fonction a **une seule responsabilité** et une docstring ; `main()` se lit comme le scénario du jeu. +- `masquer` et `mot_complet` ne font aucun `print` ni `input` : ce sont des fonctions « pures », faciles à tester (`masquer("PYTHON", {"P", "N"})` doit renvoyer `"P _ _ _ _ N"`). +- Les lettres sont stockées dans des **ensembles** (chapitre 04) : pas de doublon, test `in` rapide. +- `demander_lettre` utilise `while True` + `return` : la fonction ne rend la main qu'avec une saisie valide. +- Les constantes `MOTS` et `ERREURS_MAX` sont en majuscules et en haut du fichier : pour changer la difficulté, une seule ligne à modifier. +- Pouvoir appeler `jouer_partie("PYTHON")` directement montre l'avantage d'avoir séparé le choix du mot du déroulement de la partie. + +
+ +--- + +## Récapitulatif + +- Une fonction se **définit** avec `def nom(parametres):` et s'**appelle** avec `nom(arguments)`. Elle doit être définie avant d'être appelée. +- `return` **renvoie** une valeur au code appelant et **arrête** la fonction. Sans `return`, une fonction renvoie `None`. +- `print` montre à l'humain ; `return` donne au programme. Une fonction de calcul doit **renvoyer** son résultat. +- On peut renvoyer plusieurs valeurs (un tuple) et les déballer : `a, b = f()`. +- Les **valeurs par défaut** (`def f(x, y=0)`) rendent des paramètres optionnels ; les **arguments nommés** (`f(x=1, y=2)`) rendent les appels lisibles. +- `*args` regroupe les arguments positionnels en trop dans un **tuple**, `**kwargs` les arguments nommés en trop dans un **dictionnaire**. +- Les variables créées dans une fonction sont **locales**. Évite `global` : passe les données en paramètres et renvoie les résultats. +- Une liste passée en paramètre peut être modifiée par la fonction. **Jamais** de valeur par défaut mutable : utilise `None`. +- Une **docstring** documente la fonction juste sous le `def`. +- Une fonction **récursive** s'appelle elle-même ; elle a besoin d'un **cas de base** et d'un appel sur un problème plus petit. + +### Checklist de validation + +- [ ] Je sais écrire une fonction avec paramètres qui renvoie un résultat. +- [ ] Je sais expliquer la différence entre `print` et `return`. +- [ ] Je sais utiliser une valeur par défaut et appeler une fonction avec des arguments nommés. +- [ ] Je connais le piège de l'argument par défaut mutable et sa correction. +- [ ] Je sais pourquoi une variable créée dans une fonction n'existe pas à l'extérieur. +- [ ] J'écris une docstring pour chacune de mes fonctions. +- [ ] Je sais écrire une fonction récursive avec un cas de base. +- [ ] J'ai terminé le pendu en le découpant en fonctions. + +--- + +[← Chapitre précédent](04-structures-de-donnees.md) | [Sommaire](README.md) | [Chapitre suivant →](06-chaines-et-comprehensions.md) diff --git a/docs/presentations/Python/06-chaines-et-comprehensions.md b/docs/presentations/Python/06-chaines-et-comprehensions.md new file mode 100644 index 0000000..00b932c --- /dev/null +++ b/docs/presentations/Python/06-chaines-et-comprehensions.md @@ -0,0 +1,1598 @@ +# Chapitre 06 — Chaînes de caractères et compréhensions + +> **Durée estimée :** 5 à 6 heures (cours, exercices et mini-projet) +> +> **Prérequis :** [Chapitre 05 — Les fonctions](05-fonctions.md) (`def`, paramètres, `return`) +> +> **Objectifs — à la fin de ce chapitre, tu sauras…** +> +> - manipuler du texte avec les principales **méthodes de `str`** (`split`, `join`, `strip`, `replace`, `find`…) ; +> - extraire des morceaux de chaîne avec le **slicing** ; +> - écrire des **compréhensions** de listes, de dictionnaires et d'ensembles ; +> - parcourir plus élégamment avec **`enumerate`** et **`zip`** ; +> - trier finement avec **`sorted(..., key=...)`** et des fonctions **`lambda`** ; +> - nettoyer et analyser un texte complet. + +--- + +## Cours + +### 1. Rappels : une chaîne est une séquence + +Tu manipules des chaînes (`str`) depuis le [chapitre 01](01-variables-types-operateurs.md). Il est temps de voir qu'une chaîne se comporte **comme une liste de caractères** (mais une liste figée, comme un tuple). + +```python +mot = "Python" +print(len(mot)) # → 6 +print(mot[0]) # → P +print(mot[-1]) # → n +print("y" in mot) # → True +print("thon" in mot) # → True + +for lettre in mot: + print(lettre, end=" ") +print() +``` + +```text +6 +P +n +True +True +P y t h o n +``` + +`in` sur une chaîne cherche une **sous-chaîne** entière : `"thon" in "Python"` est vrai. + +#### Les chaînes sont immuables + +Comme les tuples, on ne peut pas modifier une chaîne « sur place » : + +```python +mot = "Python" +mot[0] = "J" +``` + +```text +Traceback (most recent call last): + File "", line 2, in +TypeError: 'str' object does not support item assignment +``` + +Toutes les « modifications » de chaîne créent en fait une **nouvelle** chaîne : + +```python +mot = "Python" +mot = "J" + mot[1:] +print(mot) # → Jython +``` + +C'est fondamental pour comprendre les méthodes qui suivent : **aucune ne modifie la chaîne d'origine**, elles **renvoient** une nouvelle chaîne. Il faut donc récupérer le résultat. + +```python +nom = "alice" +nom.upper() # le résultat est perdu ! +print(nom) # → alice +nom = nom.upper() +print(nom) # → ALICE +``` + +#### Caractères spéciaux et chaînes sur plusieurs lignes + +```python +print("Ligne 1\nLigne 2") # \n = retour à la ligne +print("Col1\tCol2") # \t = tabulation +print("Il a dit \"bonjour\"") # \" = guillemet dans la chaîne +print('C\'est facile') # \' = apostrophe +print("C:\\Users\\alice") # \\ = un antislash +``` + +```text +Ligne 1 +Ligne 2 +Col1 Col2 +Il a dit "bonjour" +C'est facile +C:\Users\alice +``` + +Les triples guillemets permettent d'écrire du texte sur plusieurs lignes : + +```python +poeme = """Mignonne, allons voir si la rose +Qui ce matin avait déclose +Sa robe de pourpre au soleil""" +print(len(poeme.splitlines())) # → 3 +``` + +--- + +### 2. Le slicing en détail + +Tu as découvert le slicing sur les listes au [chapitre 04](04-structures-de-donnees.md). Il fonctionne exactement pareil sur les chaînes : `chaine[debut:fin:pas]`, avec `fin` **exclu**. + +```text + indice positif : 0 1 2 3 4 5 6 7 8 9 + caractère : B o n j o u r ! ! ! + indice négatif : -10 -9 -8 -7 -6 -5 -4 -3 -2 -1 +``` + +```python +texte = "Bonjour!!!" +print(texte[0:3]) # → Bon +print(texte[3:7]) # → jour +print(texte[:7]) # → Bonjour +print(texte[7:]) # → !!! +print(texte[-3:]) # → !!! +print(texte[:-3]) # → Bonjour +print(texte[::2]) # → Bnor! +print(texte[::-1]) # → !!!ruojnoB +``` + +Astuce pour retenir : imagine que les indices désignent les **séparations entre les caractères**, et non les caractères eux-mêmes. `texte[3:7]` prend ce qui se trouve entre la séparation 3 et la séparation 7. + +```text + | B | o | n | j | o | u | r | ! | ! | ! | + 0 1 2 3 4 5 6 7 8 9 10 +``` + +Contrairement à l'accès par indice, le slicing ne provoque **jamais** d'erreur si on dépasse : + +```python +mot = "chat" +print(mot[2:100]) # → at +print(mot[10:] == "") # → True (on obtient une chaîne vide) +``` + +Quelques usages typiques : + +```python +fichier = "rapport_2024.pdf" +print(fichier[-4:]) # → .pdf (l'extension) +print(fichier[:-4]) # → rapport_2024 (sans l'extension) + +code_postal = "75011" +print(code_postal[:2]) # → 75 (le département) + +mot = "kayak" +print(mot == mot[::-1]) # → True (palindrome en une ligne !) +``` + +--- + +### 3. Les méthodes de chaîne essentielles + +Une méthode s'appelle avec un point : `chaine.methode(...)`. Voici celles que tu utiliseras tout le temps, regroupées par usage. + +#### Changer la casse + +```python +titre = "le Petit PRINCE" +print(titre.upper()) # → LE PETIT PRINCE +print(titre.lower()) # → le petit prince +print(titre.capitalize()) # → Le petit prince +print(titre.title()) # → Le Petit Prince +print(titre.swapcase()) # → LE pETIT prince +``` + +Pour comparer deux textes sans tenir compte des majuscules, on les met tous les deux en minuscules : + +```python +reponse = "OUI" +if reponse.lower() == "oui": + print("D'accord !") # → D'accord ! +``` + +#### Nettoyer les espaces + +`strip()` retire les espaces (et retours à la ligne, tabulations) **au début et à la fin**. C'est indispensable pour nettoyer une saisie utilisateur. + +```python +saisie = " alice@mail.fr \n" +print(f"[{saisie.strip()}]") # → [alice@mail.fr] +``` + +`lstrip()` et `rstrip()` ne nettoient qu'un seul côté (gauche ou droite) : + +```python +saisie = " bonjour " +print(f"[{saisie.strip()}]") # → [bonjour] +print(f"[{saisie.lstrip()}]") # → [bonjour ] +print(f"[{saisie.rstrip()}]") # → [ bonjour] +print("---titre---".strip("-")) # → titre +``` + +On peut passer à `strip` les caractères à retirer, comme dans la dernière ligne. + +#### Chercher + +```python +phrase = "Le chat dort. Le chat mange." +print(phrase.find("chat")) # → 3 (indice de la 1re occurrence) +print(phrase.find("chien")) # → -1 (pas trouvé) +print(phrase.count("chat")) # → 2 +print(phrase.startswith("Le")) # → True +print(phrase.endswith(".")) # → True +print(phrase.index("dort")) # → 8 +``` + +`find` renvoie `-1` si rien n'est trouvé, alors que `index` provoque une erreur `ValueError: substring not found`. Pour un simple test de présence, `in` reste le plus lisible. + +#### Remplacer + +```python +phrase = "J'aime le thé. Le thé c'est bon." +print(phrase.replace("thé", "café")) # → J'aime le café. Le café c'est bon. +print(phrase.replace("thé", "café", 1)) # → J'aime le café. Le thé c'est bon. +print(phrase.replace(".", "")) # → J'aime le thé Le thé c'est bon +``` + +Le troisième argument limite le nombre de remplacements. Remplacer par `""` revient à **supprimer**. + +#### Tester le contenu + +Ces méthodes renvoient un booléen : + +```python +print("2024".isdigit()) # → True (que des chiffres) +print("12.5".isdigit()) # → False (le point n'est pas un chiffre) +print("Bonjour".isalpha()) # → True (que des lettres) +print("Été".isalpha()) # → True (les accents sont des lettres) +print("abc123".isalnum()) # → True (lettres ou chiffres) +print(" ".isspace()) # → True +print("ALICE".isupper()) # → True +print("alice".islower()) # → True +``` + +#### Découper avec `split` et assembler avec `join` + +Ce sont les deux méthodes les plus importantes du chapitre. + +`split()` découpe une chaîne en **liste** : + +```python +phrase = "le chat dort" +print(phrase.split()) # → ['le', 'chat', 'dort'] + +date = "14/07/1789" +print(date.split("/")) # → ['14', '07', '1789'] + +ligne_csv = "Alice;30;Lyon" +nom, age, ville = ligne_csv.split(";") +print(nom, age, ville) # → Alice 30 Lyon + +print("a,b,,c".split(",")) # → ['a', 'b', '', 'c'] +``` + +Sans argument, `split()` coupe sur **n'importe quel blanc** (espaces multiples, tabulations, retours à la ligne) et ignore les blancs en trop. Avec un séparateur, il coupe exactement sur ce séparateur, d'où la chaîne vide `''` dans le dernier exemple. + +`join()` fait l'inverse : il **assemble** une liste de chaînes en une seule, en insérant un séparateur. Attention à la syntaxe, c'est le **séparateur** qui appelle la méthode : + +```python +mots = ["le", "chat", "dort"] +print(" ".join(mots)) # → le chat dort +print("-".join(mots)) # → le-chat-dort +print("".join(mots)) # → lechatdort +print(", ".join(mots)) # → le, chat, dort +print("\n".join(mots)) +``` + +```text +le chat dort +le-chat-dort +lechatdort +le, chat, dort +le +chat +dort +``` + +`join` n'accepte **que des chaînes** : + +```python +nombres = [1, 2, 3] +print("-".join(nombres)) +``` + +```text +Traceback (most recent call last): + File "", line 2, in +TypeError: sequence item 0: expected str instance, int found +``` + +Il faut d'abord convertir chaque nombre en chaîne ; on verra la façon élégante de le faire avec les compréhensions (section 6). + +Combinés, `split` et `join` permettent de transformer un texte en quelques lignes : + +```python +phrase = " python est super " +propre = " ".join(phrase.split()) +print(f"[{propre}]") # → [python est super] +``` + +#### Aligner et remplir + +```python +print("chat".center(12, "*")) # → ****chat**** +print("chat".ljust(8) + "|") # → chat | +print("chat".rjust(8) + "|") # → chat| +print("7".zfill(3)) # → 007 +``` + +--- + +### 4. Les f-strings, en plus fin + +Tu connais les f-strings depuis le chapitre 01. Après les deux-points, on peut ajouter un **format** très précis : + +```python +prix = 1234.5678 +print(f"{prix:.2f}") # → 1234.57 (2 décimales) +print(f"{prix:,.2f}") # → 1,234.57 (séparateur de milliers) +print(f"{0.256:.1%}") # → 25.6% (pourcentage) +print(f"{42:05d}") # → 00042 (5 chiffres, complété par des 0) + +nom = "Alice" +print(f"[{nom:<10}]") # → [Alice ] (aligné à gauche sur 10) +print(f"[{nom:>10}]") # → [ Alice] (aligné à droite) +print(f"[{nom:^10}]") # → [ Alice ] (centré) +print(f"[{nom:*^11}]") # → [***Alice***] (centré, rempli de *) +``` + +C'est idéal pour afficher des tableaux bien alignés : + +```python +produits = [("Pomme", 0.5, 12), ("Ananas", 2.95, 2), ("Kiwi", 0.35, 30)] +print(f"{'Produit':<10}{'Prix':>8}{'Qté':>6}") +print("-" * 24) +for nom, prix, quantite in produits: + print(f"{nom:<10}{prix:>8.2f}{quantite:>6}") +``` + +```text +Produit Prix Qté +------------------------ +Pomme 0.50 12 +Ananas 2.95 2 +Kiwi 0.35 30 +``` + +Une autre astuce pratique pour déboguer : `=` après le nom de variable affiche le nom **et** la valeur. + +```python +total = 42 +print(f"{total=}") # → total=42 +``` + +--- + +### 5. `enumerate`, `zip` et `sorted` + +Ces trois fonctions intégrées rendent les boucles beaucoup plus élégantes. + +#### `enumerate` : l'indice et la valeur + +Au chapitre 04, pour avoir l'indice, on écrivait `for i in range(len(liste))`. `enumerate` fait mieux : il fournit à chaque tour un tuple `(indice, valeur)`, qu'on déballe. + +```python +podium = ["Alice", "Bob", "Chloé"] +for i, nom in enumerate(podium): + print(i, nom) +``` + +```text +0 Alice +1 Bob +2 Chloé +``` + +Le paramètre `start` permet de commencer à compter ailleurs qu'à 0 (pratique pour l'affichage humain) : + +```python +podium = ["Alice", "Bob", "Chloé"] +for rang, nom in enumerate(podium, start=1): + print(f"{rang}. {nom}") +``` + +```text +1. Alice +2. Bob +3. Chloé +``` + +Ça marche aussi sur une chaîne : + +```python +for position, lettre in enumerate("abc"): + print(f"La lettre {lettre} est en position {position}") +``` + +```text +La lettre a est en position 0 +La lettre b est en position 1 +La lettre c est en position 2 +``` + +#### `zip` : parcourir plusieurs listes en parallèle + +`zip` associe les éléments de plusieurs collections **deux à deux** (comme une fermeture éclair, *zip* en anglais). + +```python +prenoms = ["Alice", "Bob", "Chloé"] +ages = [30, 25, 35] +villes = ["Lyon", "Paris", "Nice"] + +for prenom, age, ville in zip(prenoms, ages, villes): + print(f"{prenom}, {age} ans, habite à {ville}") +``` + +```text +Alice, 30 ans, habite à Lyon +Bob, 25 ans, habite à Paris +Chloé, 35 ans, habite à Nice +``` + +Si les listes n'ont pas la même longueur, `zip` s'arrête à la plus courte. Pour transformer deux listes en dictionnaire, `zip` est parfait : + +```python +cles = ["nom", "age", "ville"] +valeurs = ["Alice", 30, "Lyon"] +fiche = dict(zip(cles, valeurs)) +print(fiche) # → {'nom': 'Alice', 'age': 30, 'ville': 'Lyon'} +``` + +Et pour comparer des éléments voisins d'une liste, on peut « zipper » la liste avec elle-même décalée : + +```python +temperatures = [12, 15, 14, 18, 21] +for avant, apres in zip(temperatures, temperatures[1:]): + print(f"{avant} → {apres} : {apres - avant:+d}") +``` + +```text +12 → 15 : +3 +15 → 14 : -1 +14 → 18 : +4 +18 → 21 : +3 +``` + +(Le format `+d` affiche toujours le signe.) + +#### `sorted` avec `key` + +Tu sais déjà que `sorted(liste)` renvoie une copie triée et que `reverse=True` inverse l'ordre. Le paramètre `key` permet de dire **selon quel critère** trier : on lui passe une **fonction**, que `sorted` appelle sur chaque élément ; le tri se fait sur les résultats. + +```python +mots = ["kiwi", "Banane", "abricot", "Cerise", "fraise"] + +print(sorted(mots)) # → ['Banane', 'Cerise', 'abricot', 'fraise', 'kiwi'] +print(sorted(mots, key=str.lower)) # → ['abricot', 'Banane', 'Cerise', 'fraise', 'kiwi'] +print(sorted(mots, key=len)) # → ['kiwi', 'Banane', 'Cerise', 'fraise', 'abricot'] +``` + +- Par défaut, les majuscules passent avant les minuscules (`"B" < "a"`). +- `key=str.lower` compare les versions en minuscules : ordre alphabétique « naturel ». +- `key=len` trie par longueur ; à longueur égale, l'ordre d'origine est conservé (le tri est dit **stable**). + +On peut passer n'importe quelle fonction, y compris une des tiennes : + +```python +def derniere_lettre(mot): + return mot[-1] + + +mots = ["chat", "souris", "lapin", "zebre"] +print(sorted(mots, key=derniere_lettre)) # → ['zebre', 'lapin', 'souris', 'chat'] +``` + +Les fonctions `min` et `max` acceptent aussi `key` : + +```python +mots = ["chat", "hippopotame", "ours"] +print(max(mots, key=len)) # → hippopotame +``` + +--- + +### 6. Les fonctions `lambda` + +Écrire une fonction complète avec `def` juste pour la passer à `sorted` est un peu lourd. Une **lambda** est une petite fonction **anonyme**, écrite en une seule expression : + +```python +# Ces deux écritures sont équivalentes : +def double(x): + return x * 2 + + +double_lambda = lambda x: x * 2 + +print(double(5)) # → 10 +print(double_lambda(5)) # → 10 +``` + +La syntaxe est `lambda parametres: expression`. Pas de `return` : la valeur de l'expression est automatiquement renvoyée. Une lambda ne peut contenir qu'**une seule expression** (pas de `if` sur plusieurs lignes, pas de boucle). + +> On ne range pas normalement une lambda dans une variable comme ci-dessus (autant utiliser `def`). Une lambda s'utilise **directement là où on a besoin d'une fonction**, typiquement comme argument `key`. + +```python +eleves = [("Alice", 15), ("Bob", 12), ("Chloé", 18), ("David", 12)] + +# Trier par note (le 2e élément du tuple) +print(sorted(eleves, key=lambda eleve: eleve[1])) +# Trier par note décroissante +print(sorted(eleves, key=lambda eleve: eleve[1], reverse=True)) +# Meilleur élève +print(max(eleves, key=lambda eleve: eleve[1])) +``` + +```text +[('Bob', 12), ('David', 12), ('Alice', 15), ('Chloé', 18)] +[('Chloé', 18), ('Alice', 15), ('Bob', 12), ('David', 12)] +('Chloé', 18) +``` + +Avec une liste de dictionnaires : + +```python +contacts = [ + {"nom": "Durand", "age": 42}, + {"nom": "Martin", "age": 25}, + {"nom": "Bernard", "age": 33}, +] +for contact in sorted(contacts, key=lambda c: c["age"]): + print(contact["nom"], contact["age"]) +``` + +```text +Martin 25 +Bernard 33 +Durand 42 +``` + +#### Trier sur plusieurs critères + +La lambda peut renvoyer un **tuple** : Python trie d'abord selon le premier élément, puis le deuxième en cas d'égalité. Pour inverser un critère numérique, on utilise le signe moins : + +```python +eleves = [("Alice", 15), ("Bob", 12), ("Chloé", 18), ("David", 12), ("Emma", 15)] +# Note décroissante, puis nom alphabétique +classement = sorted(eleves, key=lambda e: (-e[1], e[0])) +print(classement) +``` + +```text +[('Chloé', 18), ('Alice', 15), ('Emma', 15), ('Bob', 12), ('David', 12)] +``` + +Compare avec l'exercice 8 du chapitre 04 : c'est la même idée, en une ligne. + +#### Trier un dictionnaire par valeur + +```python +scores = {"Léo": 1200, "Maya": 1850, "Sam": 900} +for nom, score in sorted(scores.items(), key=lambda paire: paire[1], reverse=True): + print(nom, score) +``` + +```text +Maya 1850 +Léo 1200 +Sam 900 +``` + +`scores.items()` fournit des tuples `(nom, score)` ; on trie selon `paire[1]`, le score. + +--- + +### 7. Les compréhensions de liste + +Tu as souvent écrit ce schéma : + +```python +nombres = [1, 2, 3, 4, 5] +carres = [] +for n in nombres: + carres.append(n * n) +print(carres) # → [1, 4, 9, 16, 25] +``` + +Python propose une syntaxe compacte pour exactement ça, la **compréhension de liste** : + +```python +nombres = [1, 2, 3, 4, 5] +carres = [n * n for n in nombres] +print(carres) # → [1, 4, 9, 16, 25] +``` + +Lis-la de gauche à droite : « la liste des `n * n` **pour** chaque `n` **dans** `nombres` ». C'est très proche de la notation mathématique { n² | n ∈ nombres }. + +```text + [ n * n for n in nombres ] + ───┬── ────────┬─────── + │ └─ la boucle : d'où viennent les éléments + └─ l'expression : ce qu'on met dans la nouvelle liste +``` + +Exemples : + +```python +prenoms = ["alice", "bob", "chloé"] +print([p.capitalize() for p in prenoms]) # → ['Alice', 'Bob', 'Chloé'] +print([len(p) for p in prenoms]) # → [5, 3, 5] + +print([i * 10 for i in range(5)]) # → [0, 10, 20, 30, 40] + +nombres = [1, 2, 3] +print("-".join([str(n) for n in nombres])) # → 1-2-3 + +prix_ht = [10, 25.5, 100] +print([round(p * 1.2, 2) for p in prix_ht]) # → [12.0, 30.6, 120.0] +``` + +Note l'exemple avec `join` : la compréhension convertit chaque nombre en chaîne, ce qui résout l'erreur vue en section 3. + +#### Filtrer avec `if` + +On peut ajouter une condition à la fin pour ne garder que certains éléments : + +```python +notes = [8, 15, 12, 6, 19, 10] +print([n for n in notes if n >= 10]) # → [15, 12, 19, 10] + +mots = ["Python", "est", "un", "langage", "génial"] +print([m for m in mots if len(m) > 3]) # → ['Python', 'langage', 'génial'] +print([m.upper() for m in mots if "a" in m]) # → ['LANGAGE', 'GÉNIAL'] +``` + +Correspondance avec la boucle classique : + +```python +# Compréhension : +resultat = [m.upper() for m in ["chat", "lion", "rat"] if "a" in m] + +# Équivaut exactement à : +resultat2 = [] +for m in ["chat", "lion", "rat"]: + if "a" in m: + resultat2.append(m.upper()) + +print(resultat == resultat2) # → True +``` + +#### Transformer avec `if/else` + +Attention, différence importante : si tu veux **transformer** chaque élément selon une condition (et non filtrer), le `if ... else` se place **avant** le `for`. C'est une expression conditionnelle (vue au chapitre 05). + +```python +notes = [8, 15, 12, 6] +print(["admis" if n >= 10 else "recalé" for n in notes]) # → ['recalé', 'admis', 'admis', 'recalé'] +``` + +Pour bien retenir la différence entre les deux formes, voici leur schéma. + +```text +[ expression for x in liste if condition ] ← FILTRE : moins d'éléments +[ valeur_si_vrai if condition else valeur_si_faux for x in liste ] ← TRANSFORME : autant d'éléments +``` + +#### Compréhensions imbriquées + +On peut enchaîner plusieurs `for`, dans le même ordre que des boucles imbriquées : + +```python +couleurs = ["rouge", "bleu"] +tailles = ["S", "M"] +print([f"{c}-{t}" for c in couleurs for t in tailles]) +# → ['rouge-S', 'rouge-M', 'bleu-S', 'bleu-M'] + +grille = [[1, 2, 3], [4, 5, 6]] +print([valeur for ligne in grille for valeur in ligne]) # aplatir +# → [1, 2, 3, 4, 5, 6] + +print([[0] * 3 for _ in range(2)]) # grille 2x3 correcte (cf. piège 8 du chapitre 04) +# → [[0, 0, 0], [0, 0, 0]] +``` + +```text +['rouge-S', 'rouge-M', 'bleu-S', 'bleu-M'] +[1, 2, 3, 4, 5, 6] +[[0, 0, 0], [0, 0, 0]] +``` + +**Conseil :** une compréhension doit rester **lisible en un coup d'œil**. Si elle dépasse une ligne ou contient plus de deux `for`, une boucle classique est souvent plus claire. + +--- + +### 8. Compréhensions de dictionnaire et d'ensemble + +Même principe, avec des accolades. + +#### Compréhension de dictionnaire + +On écrit `{cle: valeur for ...}` : + +```python +mots = ["chat", "éléphant", "rat"] +longueurs = {mot: len(mot) for mot in mots} +print(longueurs) # → {'chat': 4, 'éléphant': 8, 'rat': 3} + +prix = {"pain": 1.2, "lait": 0.95, "caviar": 89.0} +print({article: p for article, p in prix.items() if p < 10}) +# → {'pain': 1.2, 'lait': 0.95} + +print({article: round(p * 2, 2) for article, p in prix.items()}) +# → {'pain': 2.4, 'lait': 1.9, 'caviar': 178.0} + +# Inverser un dictionnaire (clés ↔ valeurs) +codes = {"FR": "France", "IT": "Italie"} +print({pays: code for code, pays in codes.items()}) +# → {'France': 'FR', 'Italie': 'IT'} +``` + +```text +{'chat': 4, 'éléphant': 8, 'rat': 3} +{'pain': 1.2, 'lait': 0.95} +{'pain': 2.4, 'lait': 1.9, 'caviar': 178.0} +{'France': 'FR', 'Italie': 'IT'} +``` + +#### Compréhension d'ensemble + +`{expression for ...}` sans deux-points crée un ensemble (donc sans doublons) : + +```python +mots = ["Chat", "chat", "CHIEN", "chien", "Lapin"] +print(sorted({m.lower() for m in mots})) # → ['chat', 'chien', 'lapin'] + +phrase = "le chat et le rat" +print(sorted({len(mot) for mot in phrase.split()})) # → [2, 3, 4] +``` + +#### Et les parenthèses ? + +`(x for x in ...)` ne crée **pas** un tuple, mais un **générateur** : un objet qui produit les valeurs une par une, sans tout stocker en mémoire. On l'utilise surtout directement dans une fonction comme `sum`, `max`, `any` ou `all` (les parenthèses de la fonction suffisent) : + +```python +nombres = [3, 8, 12, 5] +print(sum(n * n for n in nombres)) # → 242 +print(any(n > 10 for n in nombres)) # → True (au moins un ?) +print(all(n > 0 for n in nombres)) # → True (tous ?) +print(max(len(m) for m in ["a", "abc", "ab"])) # → 3 +``` + +`any` renvoie `True` si **au moins un** élément est vrai, `all` si **tous** le sont. Ils remplacent élégamment les boucles avec drapeau du chapitre 05. + +--- + +### 9. Mettre tout ensemble : nettoyer un texte + +Voici un exemple réaliste qui combine tout le chapitre : extraire les mots d'un texte, sans ponctuation ni majuscules, et trouver les plus fréquents. + +```python +def nettoyer(texte): + """Renvoie le texte en minuscules, la ponctuation remplacée par des espaces.""" + texte = texte.lower() + for signe in ".,;:!?'\"()-": + texte = texte.replace(signe, " ") + return texte + + +def compter_mots(texte): + """Renvoie un dictionnaire {mot: nombre d'apparitions}.""" + compteur = {} + for mot in nettoyer(texte).split(): + compteur[mot] = compteur.get(mot, 0) + 1 + return compteur + + +texte = """Le chat dort. Le chien aboie ! Le chat se réveille, +et le chien, lui, s'endort. C'est la vie du chat.""" + +compteur = compter_mots(texte) +plus_frequents = sorted(compteur.items(), key=lambda paire: (-paire[1], paire[0])) +for rang, (mot, nombre) in enumerate(plus_frequents[:3], start=1): + print(f"{rang}. {mot:<8} {nombre}") +``` + +```text +1. le 4 +2. chat 3 +3. chien 2 +``` + +Remarque le `for rang, (mot, nombre) in enumerate(...)` : `enumerate` fournit `(rang, (mot, nombre))`, et on déballe les deux niveaux d'un coup grâce aux parenthèses. + +--- + +## Pièges fréquents + +### Piège 1 — Oublier que les chaînes sont immuables + +```python +nom = " alice " +nom.strip() +nom.capitalize() +print(f"[{nom}]") # → [ alice ] +``` + +Les méthodes ont renvoyé de nouvelles chaînes… qui ont été perdues. **Correction :** récupérer le résultat, éventuellement en enchaînant les méthodes. + +```python +nom = " alice " +nom = nom.strip().capitalize() +print(f"[{nom}]") # → [Alice] +``` + +### Piège 2 — Écrire `join` à l'envers + +```python +mots = ["a", "b", "c"] +print(mots.join("-")) +``` + +```text +Traceback (most recent call last): + File "", line 2, in +AttributeError: 'list' object has no attribute 'join' +``` + +**Correction :** c'est le séparateur qui appelle `join` : `"-".join(mots)`. + +### Piège 3 — `join` sur des nombres + +```python +print(", ".join([1, 2, 3])) +``` + +```text +Traceback (most recent call last): + File "", line 1, in +TypeError: sequence item 0: expected str instance, int found +``` + +**Correction :** convertir d'abord : `", ".join(str(n) for n in [1, 2, 3])`. + +### Piège 4 — `split()` contre `split(" ")` + +```python +texte = "un deux trois" +print(texte.split(" ")) # → ['un', '', 'deux', '', '', 'trois'] +print(texte.split()) # → ['un', 'deux', 'trois'] +``` + +Avec `" "`, chaque espace est un séparateur, ce qui crée des chaînes vides entre deux espaces consécutifs. **Correction :** pour découper en mots, utilise `split()` sans argument. + +### Piège 5 — Confondre filtre et transformation dans une compréhension + +```python +notes = [8, 15, 12] +print([n for n in notes if n >= 10 else 0]) +``` + +```text + File "", line 2 + print([n for n in notes if n >= 10 else 0]) + ^^^^ +SyntaxError: invalid syntax +``` + +Un `if` placé **après** le `for` est un filtre et n'accepte pas de `else`. **Correction :** pour transformer, place le `if/else` **avant** le `for`. + +```python +notes = [8, 15, 12] +print([n if n >= 10 else 0 for n in notes]) # → [0, 15, 12] +``` + +### Piège 6 — Comparer des textes sans normaliser + +```python +reponse = "Oui " +if reponse == "oui": + print("Validé") +else: + print("Refusé") # → Refusé +``` + +L'utilisateur a tapé une majuscule et un espace. **Correction :** `if reponse.strip().lower() == "oui":`. + +### Piège 7 — Lambda avec un `return` ou plusieurs instructions + +```python +carre = lambda x: return x * x +``` + +```text + File "", line 1 + carre = lambda x: return x * x + ^^^^^^ +SyntaxError: invalid syntax +``` + +Une lambda contient une **expression**, pas d'instruction : pas de `return`, pas d'affectation, pas de boucle. **Correction :** `lambda x: x * x`, ou une vraie fonction `def` si la logique est plus complexe. + +### Piège 8 — Utiliser une variable de boucle après une compréhension + +```python +carres = [n * n for n in range(5)] +print(n) +``` + +```text +Traceback (most recent call last): + File "", line 2, in +NameError: name 'n' is not defined +``` + +La variable d'une compréhension est **locale** à la compréhension, contrairement à celle d'une boucle `for` classique. C'est plutôt une bonne chose : elle n'écrase pas une variable existante. + +--- + +## Exercices + +### Exercice 1 — Manipulations de base ⭐ + +Pour `email = " Jean.Dupont@Exemple.FR "`, écris le code qui : +1. nettoie les espaces et met tout en minuscules ; +2. extrait la partie avant `@` (l'identifiant) et après (le domaine) avec `split` ; +3. extrait le prénom et le nom de l'identifiant (séparés par un point) et les affiche avec une majuscule ; +4. affiche les initiales en majuscules. + +Sortie attendue : + +```text +Email : jean.dupont@exemple.fr +Identifiant : jean.dupont +Domaine : exemple.fr +Nom complet : Jean Dupont +Initiales : J.D. +``` + +### Exercice 2 — Compréhensions simples ⭐ + +Avec des compréhensions (une ligne chacune), à partir de `nombres = [3, -1, 8, 0, -5, 12, 7]` et `mots = ["Soleil", "lune", "Étoile", "ciel", "Nuage"]` : +1. la liste des nombres positifs strictement ; +2. la liste des carrés de tous les nombres ; +3. la liste où chaque négatif est remplacé par 0 ; +4. la liste des mots commençant par une majuscule ; +5. un dictionnaire `{mot en minuscules: longueur}`. + +Sortie attendue : + +```text +[3, 8, 12, 7] +[9, 1, 64, 0, 25, 144, 49] +[3, 0, 8, 0, 0, 12, 7] +['Soleil', 'Étoile', 'Nuage'] +{'soleil': 6, 'lune': 4, 'étoile': 6, 'ciel': 4, 'nuage': 5} +``` + +### Exercice 3 — Le chiffrement de César ⭐⭐ + +Écris une fonction `cesar(texte, decalage)` qui décale chaque **lettre minuscule non accentuée** (a-z) de `decalage` positions dans l'alphabet (en revenant au début après `z`) et laisse les autres caractères inchangés. Écris aussi `decoder(texte, decalage)`. + +Indice : `alphabet = "abcdefghijklmnopqrstuvwxyz"`, `alphabet.index(lettre)` donne la position, et `% 26` permet de « boucler ». + +```python +secret = cesar("rendez-vous a midi !", 3) +print(secret) +print(decoder(secret, 3)) +print(cesar("xyz", 2)) +``` + +```text +uhqghc-yrxv d plgl ! +rendez-vous a midi ! +zab +``` + +### Exercice 4 — Tableau de notes aligné ⭐⭐ + +À partir de deux listes, `eleves = ["Alice", "Bob", "Chloé", "Dominique"]` et `notes = [15.5, 9, 12.25, 17]`, affiche un tableau aligné avec un rang (`enumerate`), le nom (aligné à gauche sur 11 caractères), la note (à droite, 2 décimales) et « oui »/« non » pour l'admission (note ≥ 10). Termine par la moyenne. + +Sortie attendue : + +```text +# Élève Note Admis +1 Alice 15.50 oui +2 Bob 9.00 non +3 Chloé 12.25 oui +4 Dominique 17.00 oui +Moyenne : 13.44 +``` + +### Exercice 5 — Trier des produits ⭐⭐ + +On donne : + +```python +produits = [ + {"nom": "Clavier", "prix": 49.9, "stock": 12}, + {"nom": "souris", "prix": 19.9, "stock": 0}, + {"nom": "Écran", "prix": 189.0, "stock": 4}, + {"nom": "casque", "prix": 49.9, "stock": 7}, +] +``` + +Avec `sorted`/`min`/`max` et des lambdas, affiche : +1. les noms triés par prix croissant, puis par nom (insensible à la casse) en cas d'égalité ; +2. le nom du produit le plus cher ; +3. les noms des produits en stock, triés alphabétiquement sans tenir compte de la casse ; +4. la valeur totale du stock (somme de prix × stock), arrondie à 2 décimales. + +Sortie attendue : + +```text +['souris', 'casque', 'Clavier', 'Écran'] +Écran +['casque', 'Clavier', 'Écran'] +1704.1 +``` + +### Exercice 6 — Acronymes et titres ⭐⭐ + +Écris deux fonctions : +- `acronyme(expression)` qui renvoie les initiales en majuscules des mots, en ignorant les petits mots `le, la, les, de, des, du, et, en, un, une` (`"société nationale des chemins de fer"` → `"SNCF"`) ; +- `titre_propre(texte)` qui met une majuscule au début de chaque mot **sauf** pour les petits mots `le, la, les, de, des, du, et, en, un, une` (sauf s'il s'agit du premier mot), et réduit les espaces multiples à un seul. + +```python +print(acronyme("société nationale des chemins de fer")) +print(acronyme("organisation des nations unies")) +print(titre_propre(" le seigneur des anneaux et la communauté de l'anneau ")) +``` + +```text +SNCF +ONU +Le Seigneur des Anneaux et la Communauté de L'anneau +``` + +### Exercice 7 — Anagrammes ⭐⭐⭐ + +Deux mots sont des anagrammes s'ils contiennent exactement les mêmes lettres (« chien » et « niche »). Écris : +- `signature(mot)` : renvoie les lettres du mot en minuscules, triées, sous forme de chaîne (`"Niche"` → `"cehin"`) ; +- `sont_anagrammes(mot1, mot2)` ; +- `grouper_anagrammes(mots)` : renvoie la liste des groupes (listes) d'au moins 2 mots qui sont anagrammes entre eux, chaque groupe trié, et les groupes triés par leur premier mot. + +```python +print(signature("Niche")) +print(sont_anagrammes("chien", "Niche")) +print(sont_anagrammes("chat", "tache")) +mots = ["chien", "niche", "chine", "rage", "gare", "chat", "ironique", "onirique", "Agre"] +print(grouper_anagrammes(mots)) +``` + +```text +cehin +True +False +[['Agre', 'gare', 'rage'], ['chien', 'chine', 'niche'], ['ironique', 'onirique']] +``` + +### Exercice 8 — Validateur de plaques d'immatriculation ⭐⭐⭐ + +Une plaque française (système SIV) a le format `AB-123-CD` : 2 lettres majuscules, tiret, 3 chiffres, tiret, 2 lettres majuscules. Écris une fonction `normaliser_plaque(saisie)` qui : +- retire les espaces autour, met en majuscules ; +- accepte aussi les saisies sans tirets (`ab123cd`) ou avec des espaces à la place des tirets (`AB 123 CD`) ; +- renvoie la plaque au format `AB-123-CD` si elle est valide, ou `None` sinon. + +Indice : supprime d'abord tirets et espaces, vérifie qu'il reste 7 caractères, puis vérifie chaque morceau avec le slicing, `isalpha()` et `isdigit()`. + +```python +for saisie in ["AB-123-CD", "ab123cd", " xy 987 zt ", "A1-234-BC", "AB-12-CD", "ÉÉ-123-AA"]: + print(f"{saisie!r:15} → {normaliser_plaque(saisie)}") +``` + +```text +'AB-123-CD' → AB-123-CD +'ab123cd' → AB-123-CD +' xy 987 zt ' → XY-987-ZT +'A1-234-BC' → None +'AB-12-CD' → None +'ÉÉ-123-AA' → None +``` + +(`{saisie!r}` affiche la chaîne avec ses guillemets, ce qui rend les espaces visibles.) + +--- + +## Mini-projet — L'analyseur de texte + +### Cahier des charges + +Écris un programme qui analyse un texte et produit un rapport. Le texte est stocké dans une variable (au [chapitre 07](07-fichiers-csv-json.md), tu pourras le lire depuis un fichier). Le rapport doit contenir : + +1. le nombre de **caractères** (total et hors espaces) ; +2. le nombre de **mots**, de **mots différents** et de **phrases** (une phrase se termine par `.`, `!` ou `?`) ; +3. la **longueur moyenne** des mots ; +4. le **mot le plus long** ; +5. les **5 mots les plus fréquents** parmi les mots d'au moins 4 lettres (pour éviter « le », « de »…), avec une barre de `#` proportionnelle ; +6. les **lettres** les plus fréquentes (top 5), en ignorant les accents de base (é, è, ê → e ; à → a ; ç → c…). + +**Contraintes :** structure en fonctions avec docstrings, utilisation de compréhensions, `sorted` avec `key`, `enumerate` ; affichage aligné grâce aux f-strings. + +### Étapes suggérées + +1. `normaliser(texte)` : minuscules + remplacement des lettres accentuées (un dictionnaire `{"é": "e", ...}` et une boucle de `replace`). +2. `extraire_mots(texte)` : remplace la ponctuation par des espaces puis `split()`. Garde l'apostrophe comme séparateur (« l'analyse » → « l », « analyse »). +3. `compter_phrases(texte)` : compte les `.`, `!` et `?` (attention aux `...` ou `?!` : tu peux d'abord remplacer `...` par `.`, ou compter les morceaux non vides après découpage). +4. `frequences(elements)` : renvoie un dictionnaire de comptage. +5. `top(compteur, n)` : renvoie les `n` paires les plus fréquentes (fréquence décroissante, puis ordre alphabétique). +6. `afficher_rapport(texte)` : assemble le tout. + +### Pistes d'amélioration + +- Ignorer une liste de **mots vides** (« dans », « pour », « avec »…) au lieu du filtre sur la longueur. +- Calculer un **indice de lisibilité** simple (mots par phrase, lettres par mot). +- Comparer deux textes : mots communs, mots propres à chacun (avec des ensembles). +- Détecter la **langue** (français ou anglais) d'après la fréquence de mots courants (« le », « the »). +- Lire le texte depuis un fichier et écrire le rapport dans un autre ([chapitre 07](07-fichiers-csv-json.md)). + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +```python +email = " Jean.Dupont@Exemple.FR " + +email = email.strip().lower() +identifiant, domaine = email.split("@") +prenom, nom = identifiant.split(".") +prenom = prenom.capitalize() +nom = nom.capitalize() +initiales = f"{prenom[0]}.{nom[0]}." + +print(f"Email : {email}") +print(f"Identifiant : {identifiant}") +print(f"Domaine : {domaine}") +print(f"Nom complet : {prenom} {nom}") +print(f"Initiales : {initiales}") +``` + +Sortie : + +```text +Email : jean.dupont@exemple.fr +Identifiant : jean.dupont +Domaine : exemple.fr +Nom complet : Jean Dupont +Initiales : J.D. +``` + +On enchaîne `strip().lower()` : chaque méthode renvoie une nouvelle chaîne sur laquelle on applique la suivante. `split("@")` renvoie une liste de deux éléments qu'on déballe directement. Les initiales s'obtiennent avec `[0]`, le premier caractère. + +
+ +
+Corrigé de l'exercice 2 + +```python +nombres = [3, -1, 8, 0, -5, 12, 7] +mots = ["Soleil", "lune", "Étoile", "ciel", "Nuage"] + +print([n for n in nombres if n > 0]) +print([n * n for n in nombres]) +print([n if n >= 0 else 0 for n in nombres]) +print([m for m in mots if m[0].isupper()]) +print({m.lower(): len(m) for m in mots}) +``` + +Sortie : + +```text +[3, 8, 12, 7] +[9, 1, 64, 0, 25, 144, 49] +[3, 0, 8, 0, 0, 12, 7] +['Soleil', 'Étoile', 'Nuage'] +{'soleil': 6, 'lune': 4, 'étoile': 6, 'ciel': 4, 'nuage': 5} +``` + +Lignes 1 et 4 : `if` **après** le `for` = filtre (moins d'éléments). Ligne 3 : `if/else` **avant** le `for` = transformation (autant d'éléments). `isupper()` reconnaît aussi les majuscules accentuées comme `É`. On aurait pu écrire la ligne 3 avec `max(n, 0)`. + +
+ +
+Corrigé de l'exercice 3 + +```python +ALPHABET = "abcdefghijklmnopqrstuvwxyz" + + +def cesar(texte, decalage): + """Chiffre texte en décalant chaque lettre a-z de decalage positions.""" + resultat = [] + for caractere in texte: + if caractere in ALPHABET: + position = ALPHABET.index(caractere) + nouvelle_position = (position + decalage) % 26 + resultat.append(ALPHABET[nouvelle_position]) + else: + resultat.append(caractere) + return "".join(resultat) + + +def decoder(texte, decalage): + """Déchiffre un texte chiffré par cesar avec le même décalage.""" + return cesar(texte, -decalage) + + +secret = cesar("rendez-vous a midi !", 3) +print(secret) +print(decoder(secret, 3)) +print(cesar("xyz", 2)) +``` + +Sortie : + +```text +uhqghc-yrxv d plgl ! +rendez-vous a midi ! +zab +``` + +Le modulo `% 26` fait « boucler » l'alphabet : `x` (23) + 2 = 25 → `z`, `y` (24) + 2 = 26 % 26 = 0 → `a`. Décoder revient à chiffrer avec le décalage opposé ; en Python, `-3 % 26` vaut `23`, donc le modulo fonctionne aussi avec les nombres négatifs. On construit une liste de caractères puis on la `join` : c'est plus efficace que de concaténer des chaînes dans une boucle. + +
+ +
+Corrigé de l'exercice 4 + +```python +eleves = ["Alice", "Bob", "Chloé", "Dominique"] +notes = [15.5, 9, 12.25, 17] + +print(f"{'#':<3}{'Élève':<11}{'Note':>6} Admis") +for rang, (eleve, note) in enumerate(zip(eleves, notes), start=1): + admis = "oui" if note >= 10 else "non" + print(f"{rang:<3}{eleve:<11}{note:>6.2f} {admis}") + +moyenne = sum(notes) / len(notes) +print(f"Moyenne : {moyenne:.2f}") +``` + +Sortie : + +```text +# Élève Note Admis +1 Alice 15.50 oui +2 Bob 9.00 non +3 Chloé 12.25 oui +4 Dominique 17.00 oui +Moyenne : 13.44 +``` + +`zip` associe chaque élève à sa note, et `enumerate` ajoute un rang : on déballe `(rang, (eleve, note))`. Les formats `<3`, `<11` et `>6.2f` fixent la largeur des colonnes ; les en-têtes utilisent les mêmes largeurs pour rester alignés. Pour mettre une chaîne littérale dans une f-string délimitée par `"`, on utilise des apostrophes : `{'Élève':<11}`. + +
+ +
+Corrigé de l'exercice 5 + +```python +produits = [ + {"nom": "Clavier", "prix": 49.9, "stock": 12}, + {"nom": "souris", "prix": 19.9, "stock": 0}, + {"nom": "Écran", "prix": 189.0, "stock": 4}, + {"nom": "casque", "prix": 49.9, "stock": 7}, +] + +# 1. Prix croissant, puis nom insensible à la casse +par_prix = sorted(produits, key=lambda p: (p["prix"], p["nom"].lower())) +print([p["nom"] for p in par_prix]) + +# 2. Le plus cher +print(max(produits, key=lambda p: p["prix"])["nom"]) + +# 3. Produits en stock, ordre alphabétique sans casse +en_stock = [p["nom"] for p in produits if p["stock"] > 0] +print(sorted(en_stock, key=str.lower)) + +# 4. Valeur du stock +valeur = sum(p["prix"] * p["stock"] for p in produits) +print(round(valeur, 2)) +``` + +Sortie : + +```text +['souris', 'casque', 'Clavier', 'Écran'] +Écran +['casque', 'Clavier', 'Écran'] +1704.1 +``` + +La clé de tri `(p["prix"], p["nom"].lower())` est un tuple : à prix égal (49.9), « casque » passe avant « clavier ». `max(..., key=...)` renvoie le dictionnaire entier, dont on prend le nom. Pour la valeur du stock, une expression génératrice dans `sum` évite de créer une liste intermédiaire. Note que « Écran » arrive en dernier car `"é"` est classé après toutes les lettres non accentuées : trier parfaitement du texte français demande des outils plus avancés (module `locale`). + +
+ +
+Corrigé de l'exercice 6 + +```python +PETITS_MOTS = {"le", "la", "les", "de", "des", "du", "et", "en", "un", "une"} + + +def acronyme(expression): + """Renvoie l'acronyme formé des initiales des mots qui ne sont pas des petits mots.""" + return "".join(mot[0].upper() for mot in expression.split() if mot.lower() not in PETITS_MOTS) + + +def titre_propre(texte): + """Met une majuscule aux mots importants et réduit les espaces.""" + mots = texte.lower().split() + resultat = [] + for i, mot in enumerate(mots): + if i == 0 or mot not in PETITS_MOTS: + resultat.append(mot.capitalize()) + else: + resultat.append(mot) + return " ".join(resultat) + + +print(acronyme("société nationale des chemins de fer")) +print(acronyme("organisation des nations unies")) +print(titre_propre(" le seigneur des anneaux et la communauté de l'anneau ")) +``` + +Sortie : + +```text +SNCF +ONU +Le Seigneur des Anneaux et la Communauté de L'anneau +``` + +Les petits mots sont rangés dans un **ensemble** partagé par les deux fonctions : le test `in` y est rapide, et la liste n'est écrite qu'une fois. Pour l'acronyme, une expression génératrice filtre les petits mots et prend l'initiale de chacun des autres ; `join` les assemble. Une règle plus simple comme « mots de plus de 2 lettres » ne marcherait pas : « des » fait 3 lettres et donnerait `SNDCF`. Pour `titre_propre`, `split()` sans argument élimine les espaces multiples, et `enumerate` permet de traiter le premier mot à part. `capitalize()` sur « l'anneau » donne « L'anneau » : gérer parfaitement les apostrophes demanderait de découper aussi sur `'`. + +
+ +
+Corrigé de l'exercice 7 + +```python +def signature(mot): + """Renvoie les lettres du mot, en minuscules, triées alphabétiquement.""" + return "".join(sorted(mot.lower())) + + +def sont_anagrammes(mot1, mot2): + """Renvoie True si les deux mots sont des anagrammes.""" + return signature(mot1) == signature(mot2) + + +def grouper_anagrammes(mots): + """Renvoie les groupes d'au moins deux anagrammes, triés.""" + groupes = {} + for mot in mots: + cle = signature(mot) + if cle not in groupes: + groupes[cle] = [] + groupes[cle].append(mot) + resultat = [sorted(groupe, key=str.lower) for groupe in groupes.values() if len(groupe) >= 2] + return sorted(resultat, key=lambda groupe: groupe[0].lower()) + + +print(signature("Niche")) +print(sont_anagrammes("chien", "Niche")) +print(sont_anagrammes("chat", "tache")) +mots = ["chien", "niche", "chine", "rage", "gare", "chat", "ironique", "onirique", "Agre"] +print(grouper_anagrammes(mots)) +``` + +Sortie : + +```text +cehin +True +False +[['Agre', 'gare', 'rage'], ['chien', 'chine', 'niche'], ['ironique', 'onirique']] +``` + +L'idée clé : deux anagrammes ont la **même signature** (leurs lettres triées). `sorted("niche")` renvoie une liste de caractères, qu'on rassemble avec `"".join`. Pour grouper, on utilise un dictionnaire `{signature: [mots]}` : tous les anagrammes atterrissent sous la même clé. Une compréhension filtre ensuite les groupes d'au moins deux mots et trie chacun. + +
+ +
+Corrigé de l'exercice 8 + +```python +def normaliser_plaque(saisie): + """Renvoie la plaque au format AB-123-CD, ou None si la saisie est invalide.""" + brut = saisie.strip().upper().replace("-", "").replace(" ", "") + if len(brut) != 7: + return None + + debut, chiffres, fin = brut[:2], brut[2:5], brut[5:] + lettres_ok = all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" for c in debut + fin) + if not lettres_ok or not chiffres.isdigit(): + return None + return f"{debut}-{chiffres}-{fin}" + + +for saisie in ["AB-123-CD", "ab123cd", " xy 987 zt ", "A1-234-BC", "AB-12-CD", "ÉÉ-123-AA"]: + print(f"{saisie!r:15} → {normaliser_plaque(saisie)}") +``` + +Sortie : + +```text +'AB-123-CD' → AB-123-CD +'ab123cd' → AB-123-CD +' xy 987 zt ' → XY-987-ZT +'A1-234-BC' → None +'AB-12-CD' → None +'ÉÉ-123-AA' → None +``` + +On normalise d'abord (majuscules, sans tirets ni espaces), puis on découpe avec le slicing. `isalpha()` aurait accepté `É` (c'est une lettre !), d'où le test explicite avec la liste des 26 lettres et `all(...)`. Plusieurs `return None` permettent de sortir dès qu'un problème est détecté. Plus tard, tu pourras découvrir les expressions régulières (module `re` de la bibliothèque standard, voir le [chapitre 09](09-modules-bibliotheque-standard.md) pour les modules), conçues pour ce genre de validation. + +
+ +
+Corrigé du mini-projet + +```python +ACCENTS = { + "à": "a", "â": "a", "ä": "a", + "é": "e", "è": "e", "ê": "e", "ë": "e", + "î": "i", "ï": "i", + "ô": "o", "ö": "o", + "ù": "u", "û": "u", "ü": "u", + "ç": "c", "œ": "oe", "æ": "ae", +} +PONCTUATION = ".,;:!?'\"()[]«»-–—…’" + + +def normaliser(texte): + """Met le texte en minuscules et retire les accents.""" + texte = texte.lower() + for accentue, simple in ACCENTS.items(): + texte = texte.replace(accentue, simple) + return texte + + +def extraire_mots(texte): + """Renvoie la liste des mots du texte, sans ponctuation.""" + for signe in PONCTUATION: + texte = texte.replace(signe, " ") + return texte.split() + + +def compter_phrases(texte): + """Compte les phrases : morceaux non vides entre deux signes . ! ou ?""" + for signe in "!?": + texte = texte.replace(signe, ".") + morceaux = texte.split(".") + return len([m for m in morceaux if m.strip() != ""]) + + +def frequences(elements): + """Renvoie un dictionnaire {element: nombre d'apparitions}.""" + compteur = {} + for element in elements: + compteur[element] = compteur.get(element, 0) + 1 + return compteur + + +def top(compteur, n): + """Renvoie les n paires (element, nombre) les plus fréquentes.""" + paires = sorted(compteur.items(), key=lambda p: (-p[1], p[0])) + return paires[:n] + + +def afficher_top(titre, paires): + """Affiche un classement avec une barre proportionnelle.""" + print(f"\n{titre}") + maximum = paires[0][1] + for rang, (element, nombre) in enumerate(paires, start=1): + barre = "#" * round(nombre / maximum * 20) + print(f" {rang}. {element:<12}{nombre:>3} {barre}") + + +def afficher_rapport(texte): + """Analyse le texte et affiche un rapport complet.""" + mots = extraire_mots(texte.lower()) + mots_normalises = extraire_mots(normaliser(texte)) + nb_caracteres = len(texte) + nb_sans_espaces = len([c for c in texte if not c.isspace()]) + longueur_moyenne = sum(len(m) for m in mots) / len(mots) + plus_long = max(mots, key=len) + + print("=" * 40) + print(f"{'RAPPORT D’ANALYSE':^40}") + print("=" * 40) + print(f"Caractères (total) : {nb_caracteres}") + print(f"Caractères (hors espaces): {nb_sans_espaces}") + print(f"Mots : {len(mots)}") + print(f"Mots différents : {len(set(mots))}") + print(f"Phrases : {compter_phrases(texte)}") + print(f"Longueur moyenne des mots: {longueur_moyenne:.2f}") + print(f"Mot le plus long : {plus_long} ({len(plus_long)} lettres)") + + mots_longs = [m for m in mots_normalises if len(m) >= 4] + afficher_top("Mots les plus fréquents (4 lettres et +) :", top(frequences(mots_longs), 5)) + + lettres = [c for c in normaliser(texte) if c.isalpha()] + afficher_top("Lettres les plus fréquentes :", top(frequences(lettres), 5)) + + +texte = """Python est un langage de programmation. Python est apprécié +pour sa lisibilité : un programme Python se lit presque comme de l'anglais ! +Les débutants apprécient Python, les experts aussi. Pourquoi ? Parce que +le langage est simple, puissant et polyvalent. Programmer en Python, +c'est programmer avec plaisir.""" + +afficher_rapport(texte) +``` + +Sortie : + +```text +======================================== + RAPPORT D’ANALYSE +======================================== +Caractères (total) : 309 +Caractères (hors espaces): 262 +Mots : 47 +Mots différents : 35 +Phrases : 6 +Longueur moyenne des mots: 5.32 +Mot le plus long : programmation (13 lettres) + +Mots les plus fréquents (4 lettres et +) : + 1. python 5 #################### + 2. langage 2 ######## + 3. programmer 2 ######## + 4. anglais 1 #### + 5. apprecie 1 #### + +Lettres les plus fréquentes : + 1. e 33 #################### + 2. p 22 ############# + 3. a 21 ############# + 4. s 19 ############ + 5. t 19 ############ +``` + +**Points clés de la solution :** + +- Chaque fonction a une responsabilité unique : `normaliser`, `extraire_mots`, `frequences` et `top` sont réutilisables pour d'autres analyses (et `frequences` sert deux fois : pour les mots et pour les lettres). +- Les accents sont gérés par un **dictionnaire de correspondances** parcouru avec `items()`. +- On compte les mots sur le texte en minuscules (en gardant les accents pour l'affichage du mot le plus long) et les fréquences sur le texte normalisé. +- `compter_phrases` uniformise les terminaisons, découpe, puis ne garde que les morceaux non vides, ce qui gère les `?!` ou les `...`. +- `top` trie par fréquence décroissante puis ordre alphabétique grâce à la clé `(-p[1], p[0])` : le classement est stable et reproductible. +- La longueur de la barre est proportionnelle au maximum (`nombre / maximum * 20`), quelle que soit la taille du texte. + +
+ +--- + +## Récapitulatif + +- Une chaîne est une **séquence immuable** : indices, slicing, `in`, `len` et `for` fonctionnent, mais toute « modification » crée une nouvelle chaîne qu'il faut récupérer. +- Slicing : `s[debut:fin:pas]`, `fin` exclu ; `s[::-1]` inverse ; pas d'erreur en cas de dépassement. +- Méthodes clés : `lower/upper/capitalize/title`, `strip`, `find/count/startswith/endswith`, `replace`, `isdigit/isalpha`, **`split`** (chaîne → liste) et **`join`** (liste → chaîne, appelé sur le séparateur). +- Les f-strings acceptent des formats : `{x:.2f}`, `{nom:<10}`, `{n:>5}`, `{p:.1%}`, `{x=}`. +- `enumerate(liste, start=1)` donne `(indice, valeur)` ; `zip(a, b)` parcourt plusieurs listes en parallèle. +- `sorted(..., key=fonction, reverse=True)` trie selon un critère ; une clé tuple permet plusieurs critères. +- `lambda params: expression` crée une petite fonction anonyme, idéale pour `key`. +- Compréhensions : `[expr for x in seq if cond]` (liste), `{k: v for ...}` (dictionnaire), `{expr for ...}` (ensemble) ; `if` final = filtre, `a if cond else b` en tête = transformation. +- `sum(...)`, `any(...)`, `all(...)`, `max(...)` acceptent une expression génératrice. + +### Checklist de validation + +- [ ] Je sais extraire n'importe quel morceau d'une chaîne avec le slicing. +- [ ] Je sais nettoyer une saisie avec `strip()` et `lower()` et en récupérer le résultat. +- [ ] Je sais passer d'une chaîne à une liste avec `split` et revenir avec `join`. +- [ ] Je sais aligner un tableau avec les f-strings. +- [ ] J'utilise `enumerate` au lieu de `range(len(...))`. +- [ ] Je sais trier une liste de dictionnaires avec `sorted` et une `lambda`. +- [ ] Je sais écrire une compréhension avec filtre, et une avec transformation `if/else`. +- [ ] J'ai terminé l'analyseur de texte. + +--- + +[← Chapitre précédent](05-fonctions.md) | [Sommaire](README.md) | [Chapitre suivant →](07-fichiers-csv-json.md) diff --git a/docs/presentations/Python/07-fichiers-csv-json.md b/docs/presentations/Python/07-fichiers-csv-json.md new file mode 100644 index 0000000..888190d --- /dev/null +++ b/docs/presentations/Python/07-fichiers-csv-json.md @@ -0,0 +1,2010 @@ +# Chapitre 07 — Les fichiers : texte, CSV et JSON + +> **Durée estimée :** 5 à 6 heures (cours, exercices et mini-projet) +> +> **Prérequis :** [Chapitre 06 — Chaînes et compréhensions](06-chaines-et-comprehensions.md) (méthodes de `str`, `split`/`join`, compréhensions) +> +> **Objectifs — à la fin de ce chapitre, tu sauras…** +> +> - manipuler des chemins de fichiers proprement avec **`pathlib`** ; +> - ouvrir un fichier avec **`with open(...)`** dans le bon **mode** (`r`, `w`, `a`) et le bon **encodage** (UTF-8) ; +> - lire un fichier texte en entier ou ligne par ligne, et écrire dedans ; +> - lire et écrire des fichiers **CSV** avec `csv.reader`, `csv.DictReader` et `csv.DictWriter` ; +> - sauvegarder et recharger des données Python au format **JSON** ; +> - rendre un programme **persistant** : ses données survivent à sa fermeture. + +--- + +## Cours + +### 1. Pourquoi des fichiers ? + +Jusqu'ici, toutes les données de tes programmes disparaissaient à la fin de l'exécution. Ta liste de courses du chapitre 04, aussi belle soit-elle, repartait de zéro à chaque lancement. C'est parce que les variables vivent dans la **mémoire vive** (RAM) de l'ordinateur, qui est vidée quand le programme se termine. + +**Analogie :** la mémoire vive, c'est ton bureau : rapide d'accès, mais on le débarrasse le soir. Un fichier, c'est un classeur rangé dans une armoire : un peu plus long à consulter, mais il est encore là le lendemain. + +Les fichiers servent à : + +- **sauvegarder** des données (scores, contacts, paramètres) pour les retrouver plus tard ; +- **lire** des données produites ailleurs (un export de tableur, un fichier de configuration…) ; +- **échanger** avec d'autres programmes : le format CSV s'ouvre dans Excel ou LibreOffice, le format JSON est la langue commune du web. + +Dans ce chapitre, on travaille avec trois sortes de fichiers **texte** : + +| Format | Extension | Usage | Exemple de contenu | +|---|---|---|---| +| texte brut | `.txt` | notes, journaux, listes simples | `Acheter du pain` | +| CSV | `.csv` | tableaux (lignes et colonnes) | `nom;age;ville` | +| JSON | `.json` | données structurées (listes, dictionnaires) | `{"nom": "Alice", "age": 30}` | + +--- + +### 2. Les chemins de fichiers avec `pathlib` + +Avant d'ouvrir un fichier, il faut savoir **où** il se trouve. C'est le rôle du **chemin** (*path*). + +#### Chemins absolus et relatifs + +- Un chemin **absolu** part de la racine du disque : `/home/alice/projets/notes.txt` (Linux, macOS) ou `C:\Users\alice\projets\notes.txt` (Windows). +- Un chemin **relatif** part du **répertoire courant**, c'est-à-dire le dossier depuis lequel tu as lancé ton programme : `notes.txt`, `donnees/contacts.json`. + +> **Attention :** le répertoire courant est le dossier ouvert dans ton terminal au moment où tu tapes `python mon_script.py`, pas forcément le dossier où se trouve le script ! Dans VS Code, c'est généralement le dossier ouvert dans l'explorateur (cf. [chapitre 00](00-installation-environnement.md)). On verra plus bas comment construire un chemin fiable. + +#### Le module `pathlib` et l'objet `Path` + +Les chemins s'écrivent différemment selon le système (`/` sous Linux et macOS, `\` sous Windows). Le module `pathlib` de la bibliothèque standard règle ce problème : on manipule des objets `Path` qui s'adaptent tout seuls au système. + +On l'importe avec une ligne `from ... import ...` en haut du fichier (les imports seront détaillés au [chapitre 09](09-modules-bibliotheque-standard.md)) : + +```python +from pathlib import Path + +dossier = Path("donnees") +fichier = dossier / "contacts.json" # l'opérateur / assemble les morceaux de chemin +print(fichier) # → donnees/contacts.json +print(fichier.name) # → contacts.json +print(fichier.stem) # → contacts +print(fichier.suffix) # → .json +print(fichier.parent) # → donnees +``` + +(Sous Windows, le premier `print` affichera `donnees\contacts.json` : c'est justement l'intérêt de `pathlib`.) + +L'opérateur `/` est la grande astuce de `pathlib` : il colle les morceaux de chemin avec le bon séparateur. Plus besoin de concaténer des chaînes à la main. + +| Attribut ou méthode | Résultat pour `Path("donnees/contacts.json")` | +|---|---| +| `.name` | `contacts.json` (nom complet) | +| `.stem` | `contacts` (nom sans extension) | +| `.suffix` | `.json` (extension) | +| `.parent` | `donnees` (dossier parent) | +| `.exists()` | `True` si le fichier ou dossier existe | +| `.is_file()` / `.is_dir()` | `True` si c'est un fichier / un dossier | +| `.with_suffix(".csv")` | `donnees/contacts.csv` (change l'extension) | + +#### Quelques chemins utiles + +```python +from pathlib import Path + +print(Path.cwd()) # le répertoire courant (current working directory) +print(Path.home()) # ton dossier personnel, ex. /home/alice ou C:\Users\alice +``` + +Pour obtenir le dossier **où se trouve ton script** (et non le répertoire courant), on utilise la variable spéciale `__file__` : + +```python +from pathlib import Path + +DOSSIER_SCRIPT = Path(__file__).parent +FICHIER_DONNEES = DOSSIER_SCRIPT / "contacts.json" +``` + +Ainsi, ton programme trouvera toujours `contacts.json` à côté du script, quel que soit le dossier d'où tu le lances. C'est la méthode recommandée pour les vrais projets. (Ce code ne fonctionne que dans un fichier `.py`, pas dans la console interactive.) + +#### Créer des dossiers, tester l'existence, lister + +```python +from pathlib import Path + +dossier = Path("essais") / "sous_dossier" +dossier.mkdir(parents=True, exist_ok=True) # crée aussi les dossiers parents, sans erreur s'il existe déjà +print(dossier.exists()) # → True +print(dossier.is_dir()) # → True +print(Path("inexistant.txt").exists()) # → False +``` + +- `parents=True` : crée tous les dossiers intermédiaires manquants (ici `essais`). +- `exist_ok=True` : ne provoque pas d'erreur si le dossier existe déjà. + +Pour lister le contenu d'un dossier, `iterdir()` renvoie tous les éléments et `glob("*.txt")` ceux qui correspondent à un motif (`*` signifie « n'importe quoi ») : + +```python +from pathlib import Path + +dossier = Path("essais") +for nom in ["a.txt", "b.txt", "c.csv"]: + (dossier / nom).write_text("contenu\n", encoding="utf-8") # crée des fichiers (expliqué plus bas) + +print(sorted(p.name for p in dossier.iterdir())) +print(sorted(p.name for p in dossier.glob("*.txt"))) +``` + +```text +['a.txt', 'b.txt', 'c.csv', 'sous_dossier'] +['a.txt', 'b.txt'] +``` + +L'ordre renvoyé par `iterdir` et `glob` n'est pas garanti : on utilise `sorted` pour un résultat stable. `glob("**/*.txt")` cherche aussi dans les sous-dossiers. + +--- + +### 3. Ouvrir un fichier : `open`, `with` et les modes + +#### La fonction `open` + +Pour lire ou écrire dans un fichier, on l'**ouvre** avec `open(chemin, mode, encoding=...)`. On obtient un objet « fichier » sur lequel on peut lire ou écrire. Puis il faut le **fermer**, pour que tout soit bien enregistré et que le fichier soit libéré. + +#### Le bloc `with` : la bonne façon de faire + +On ouvre **toujours** un fichier avec `with` : + +```python +from pathlib import Path + +chemin = Path("bonjour.txt") +with open(chemin, "w", encoding="utf-8") as fichier: + fichier.write("Bonjour le monde !\n") + fichier.write("Deuxième ligne, avec des accents : é è ç ù.\n") + +print("Fichier écrit, et déjà fermé.") +``` + +```text +Fichier écrit, et déjà fermé. +``` + +Lis-le ainsi : « avec le fichier ouvert sous le nom `fichier`, fais… ». Dès qu'on sort du bloc indenté, Python **ferme automatiquement** le fichier, même si une erreur survient au milieu. C'est comme une porte qui se referme toute seule derrière toi. + +Ouvre maintenant `bonjour.txt` dans VS Code : tu y verras tes deux lignes. + +#### Les modes d'ouverture + +Le deuxième argument de `open` indique ce qu'on veut faire : + +| Mode | Nom | Si le fichier existe | S'il n'existe pas | +|---|---|---|---| +| `"r"` | lecture (*read*), **par défaut** | on le lit | **erreur** `FileNotFoundError` | +| `"w"` | écriture (*write*) | **son contenu est effacé !** | il est créé | +| `"a"` | ajout (*append*) | on écrit **à la fin** | il est créé | +| `"x"` | création exclusive | **erreur** `FileExistsError` | il est créé | + +Le mode `"w"` est dangereux : il vide le fichier dès l'ouverture, avant même d'écrire quoi que ce soit. Pour ajouter des lignes sans rien perdre, utilise `"a"`. + +#### L'encodage : toujours `encoding="utf-8"` + +Un fichier ne contient que des octets (des nombres). L'**encodage** est la règle qui traduit ces octets en caractères. Le standard universel aujourd'hui est **UTF-8**, qui gère tous les caractères : accents, emojis, alphabets du monde entier. + +Si tu ne précises rien, Python utilise l'encodage par défaut **de ton système**, qui n'est pas forcément UTF-8 (sous Windows, c'est souvent `cp1252`). Résultat : un fichier écrit sur un ordinateur peut afficher `é` au lieu de `é` sur un autre. Voici ce qui se passe quand on relit un fichier UTF-8 avec le mauvais encodage : + +```python +from pathlib import Path + +with open(Path("bonjour.txt"), "r", encoding="latin-1") as fichier: + print(fichier.read()) +``` + +```text +Bonjour le monde ! +Deuxième ligne, avec des accents : é è ç ù. +``` + +**Règle d'or de ce chapitre :** écris **toujours** `encoding="utf-8"` quand tu ouvres un fichier texte. Pour lire comme pour écrire. + +--- + +### 4. Lire un fichier texte + +Préparons un fichier de travail : + +```python +from pathlib import Path + +with open(Path("fruits.txt"), "w", encoding="utf-8") as fichier: + fichier.write("pomme\n") + fichier.write("banane\n") + fichier.write("cerise\n") +``` + +Il existe plusieurs façons de lire, selon le besoin. + +#### Tout lire d'un coup : `read()` + +```python +from pathlib import Path + +with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier: + contenu = fichier.read() + +print(repr(contenu)) +print(contenu) +``` + +```text +'pomme\nbanane\ncerise\n' +pomme +banane +cerise + +``` + +`read()` renvoie **une seule chaîne** contenant tout le fichier, retours à la ligne `\n` compris. `repr()` permet de voir ces caractères invisibles (d'où la ligne vide à la fin du second affichage : c'est le dernier `\n`, plus celui ajouté par `print`). + +Comme on lit, on pourrait omettre `"r"` (c'est le mode par défaut), mais l'écrire rend le code plus explicite. + +#### Ligne par ligne : boucler sur le fichier + +Un objet fichier se parcourt avec `for`, **ligne par ligne**. C'est la méthode à privilégier, surtout pour les gros fichiers, car Python ne charge qu'une ligne à la fois en mémoire. + +```python +from pathlib import Path + +with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier: + for numero, ligne in enumerate(fichier, start=1): + print(f"{numero}: {ligne}") +``` + +```text +1: pomme + +2: banane + +3: cerise + +``` + +Pourquoi ces lignes vides ? Chaque `ligne` se termine par `\n`, et `print` en ajoute un autre. On retire donc presque toujours le retour à la ligne avec `strip()` (ou `rstrip("\n")` pour ne retirer que lui) : + +```python +from pathlib import Path + +with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier: + for numero, ligne in enumerate(fichier, start=1): + ligne = ligne.strip() + print(f"{numero}: {ligne}") +``` + +```text +1: pomme +2: banane +3: cerise +``` + +#### Obtenir une liste de lignes + +Pour récupérer une **liste** de lignes propres, la façon la plus simple combine `read()` et `splitlines()` (vue au chapitre 06), qui découpe sur les retours à la ligne et les supprime : + +```python +from pathlib import Path + +with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier: + fruits = fichier.read().splitlines() + +print(fruits) # → ['pomme', 'banane', 'cerise'] +``` + +Il existe aussi `fichier.readlines()`, qui renvoie une liste de lignes **avec** leur `\n` final, et `fichier.readline()` (sans s), qui lit une seule ligne à la fois. + +#### Les raccourcis de `pathlib` : `read_text` et `write_text` + +Pour les cas simples (lire ou écrire tout un fichier d'un coup), un objet `Path` propose deux méthodes qui ouvrent et ferment le fichier pour toi : + +```python +from pathlib import Path + +chemin = Path("message.txt") +chemin.write_text("Salut !\nÀ bientôt.\n", encoding="utf-8") # mode "w" : écrase ! + +texte = chemin.read_text(encoding="utf-8") +print(texte.splitlines()) # → ['Salut !', 'À bientôt.'] +``` + +C'est très pratique, mais `write_text` remplace tout le contenu, comme le mode `"w"`. Pour ajouter à la fin ou traiter un gros fichier ligne par ligne, reviens à `with open(...)`. + +#### Que se passe-t-il si le fichier n'existe pas ? + +```python +from pathlib import Path + +with open(Path("inexistant.txt"), "r", encoding="utf-8") as fichier: + print(fichier.read()) +``` + +```text +Traceback (most recent call last): + File "", line 3, in +FileNotFoundError: [Errno 2] No such file or directory: 'inexistant.txt' +``` + +Pour l'instant, la solution est de **vérifier avant** avec `exists()` : + +```python +from pathlib import Path + +chemin = Path("inexistant.txt") +if chemin.exists(): + print(chemin.read_text(encoding="utf-8")) +else: + print(f"Le fichier {chemin} n'existe pas encore.") # → Le fichier inexistant.txt n'existe pas encore. +``` + +> Au [chapitre 08](08-erreurs-et-debogage.md), tu apprendras à **intercepter** ce genre d'erreur avec `try/except`, ce qui est souvent plus robuste. + +--- + +### 5. Écrire dans un fichier + +#### `write()` : écrire une chaîne + +`fichier.write(chaine)` écrit la chaîne **telle quelle** : il n'ajoute **pas** de retour à la ligne, contrairement à `print`. Il faut mettre les `\n` soi-même. + +```python +from pathlib import Path + +with open(Path("sans_retour.txt"), "w", encoding="utf-8") as fichier: + fichier.write("un") + fichier.write("deux") + fichier.write("trois") + +print(Path("sans_retour.txt").read_text(encoding="utf-8")) # → undeuxtrois +``` + +`write()` n'accepte que des chaînes : pour écrire un nombre, convertis-le avec `str()` ou une f-string. + +```python +from pathlib import Path + +notes = [12, 15.5, 9] +with open(Path("notes.txt"), "w", encoding="utf-8") as fichier: + for note in notes: + fichier.write(f"{note}\n") + +print(Path("notes.txt").read_text(encoding="utf-8").splitlines()) # → ['12', '15.5', '9'] +``` + +Pour écrire une liste de lignes d'un coup, utilise `join` : + +```python +from pathlib import Path + +villes = ["Paris", "Lyon", "Marseille"] +with open(Path("villes.txt"), "w", encoding="utf-8") as fichier: + fichier.write("\n".join(villes) + "\n") + +print(Path("villes.txt").read_text(encoding="utf-8").splitlines()) # → ['Paris', 'Lyon', 'Marseille'] +``` + +#### `print(..., file=...)` : écrire comme on affiche + +Tu peux aussi utiliser `print` en lui indiquant un fichier : il ajoute alors automatiquement les espaces et le retour à la ligne, comme à l'écran. + +```python +from pathlib import Path + +with open(Path("rapport.txt"), "w", encoding="utf-8") as fichier: + print("Rapport du jour", file=fichier) + print("Total :", 42, "ventes", file=fichier) + +print(Path("rapport.txt").read_text(encoding="utf-8"), end="") +``` + +```text +Rapport du jour +Total : 42 ventes +``` + +#### Le mode `"a"` : ajouter à la fin + +Le mode ajout est idéal pour un **journal** (*log*) : chaque exécution ajoute des lignes sans effacer les précédentes. + +```python +from pathlib import Path + +journal = Path("journal.txt") + +with open(journal, "w", encoding="utf-8") as fichier: # on repart d'un fichier vide + fichier.write("Démarrage\n") + +with open(journal, "a", encoding="utf-8") as fichier: # ajout + fichier.write("Connexion d'Alice\n") + +with open(journal, "a", encoding="utf-8") as fichier: # nouvel ajout + fichier.write("Connexion de Bob\n") + +print(journal.read_text(encoding="utf-8"), end="") +``` + +```text +Démarrage +Connexion d'Alice +Connexion de Bob +``` + +Si on avait utilisé `"w"` à chaque fois, il ne resterait que la dernière ligne. + +#### Exemple complet : traiter un fichier et en écrire un autre + +Un schéma très courant : lire un fichier, transformer chaque ligne, écrire le résultat dans un autre fichier. Ici, on nettoie une liste d'inscrits (espaces superflus, lignes vides, majuscules incohérentes, doublons). + +```python +from pathlib import Path + +brut = Path("inscrits_brut.txt") +brut.write_text(" alice MARTIN\n\nBob durand \nalice martin\n \nchloé petit\n", encoding="utf-8") + +propres = [] +with open(brut, "r", encoding="utf-8") as entree: + for ligne in entree: + nom = " ".join(ligne.split()).title() # espaces normalisés + majuscules + if nom != "" and nom not in propres: # ignore lignes vides et doublons + propres.append(nom) + +with open(Path("inscrits.txt"), "w", encoding="utf-8") as sortie: + for nom in sorted(propres): + sortie.write(nom + "\n") + +print(Path("inscrits.txt").read_text(encoding="utf-8"), end="") +``` + +```text +Alice Martin +Bob Durand +Chloé Petit +``` + +--- + +### 6. Les fichiers CSV + +#### Le format CSV + +**CSV** signifie *Comma-Separated Values*, « valeurs séparées par des virgules ». C'est le format le plus simple pour stocker un **tableau** : une ligne du fichier par ligne du tableau, et un séparateur entre les colonnes. La première ligne contient généralement les **en-têtes** (noms des colonnes). + +```text +nom,age,ville +Alice,30,Lyon +Bob,25,Paris +``` + +En France, les tableurs utilisent souvent le **point-virgule** `;` comme séparateur, car la virgule sert de séparateur décimal (`12,5`). Tous les tableurs (Excel, LibreOffice Calc, Google Sheets) savent ouvrir et exporter du CSV. + +**Pourquoi ne pas simplement utiliser `split(",")` ?** Parce qu'une valeur peut elle-même contenir une virgule ou un retour à la ligne. Le format CSV prévoit alors de l'entourer de guillemets : `"Dupont, Jean",42`. Un `split` naïf couperait au mauvais endroit. Le module `csv` de la bibliothèque standard gère tous ces cas pour toi. + +#### Écrire un CSV avec `csv.writer` + +```python +import csv +from pathlib import Path + +eleves = [ + ["nom", "note", "classe"], + ["Alice", 15.5, "3A"], + ["Dupont, Jean", 12, "3B"], + ["Chloé", 18, "3A"], +] + +with open(Path("eleves.csv"), "w", encoding="utf-8", newline="") as fichier: + ecrivain = csv.writer(fichier) + ecrivain.writerows(eleves) # écrit toutes les lignes d'un coup + +print(Path("eleves.csv").read_text(encoding="utf-8"), end="") +``` + +```text +nom,note,classe +Alice,15.5,3A +"Dupont, Jean",12,3B +Chloé,18,3A +``` + +Points importants : + +- **`newline=""`** est obligatoire pour les fichiers CSV : le module `csv` gère lui-même les fins de ligne. Sans cela, sous Windows, tu obtiendrais une ligne vide entre chaque ligne. +- `writerow(liste)` écrit **une** ligne, `writerows(liste_de_listes)` en écrit **plusieurs**. +- Les nombres sont convertis automatiquement en texte. +- La valeur contenant une virgule a été entourée de guillemets automatiquement. + +#### Lire un CSV avec `csv.reader` + +```python +import csv +from pathlib import Path + +with open(Path("eleves.csv"), "r", encoding="utf-8", newline="") as fichier: + lecteur = csv.reader(fichier) + for ligne in lecteur: + print(ligne) +``` + +```text +['nom', 'note', 'classe'] +['Alice', '15.5', '3A'] +['Dupont, Jean', '12', '3B'] +['Chloé', '18', '3A'] +``` + +Chaque ligne devient une **liste de chaînes**. Remarque deux choses : + +1. Les guillemets autour de `Dupont, Jean` ont été correctement interprétés. +2. **Tout est du texte** : `'15.5'` et `'12'` sont des chaînes ! Pour calculer, il faut convertir avec `float()` ou `int()`. + +Pour sauter la ligne d'en-têtes, on peut utiliser `next(lecteur)`, qui lit la ligne suivante : + +```python +import csv +from pathlib import Path + +with open(Path("eleves.csv"), "r", encoding="utf-8", newline="") as fichier: + lecteur = csv.reader(fichier) + en_tetes = next(lecteur) # consomme la première ligne + notes = [float(ligne[1]) for ligne in lecteur] + +print(en_tetes) # → ['nom', 'note', 'classe'] +print(notes) # → [15.5, 12.0, 18.0] +print(f"Moyenne : {sum(notes) / len(notes):.2f}") # → Moyenne : 15.17 +``` + +#### Changer de séparateur : `delimiter` + +Pour un CSV « à la française » avec des points-virgules, on précise `delimiter=";"`, à la lecture comme à l'écriture : + +```python +import csv +from pathlib import Path + +Path("depenses.csv").write_text( + "date;libelle;montant\n2024-03-01;Courses;54,20\n2024-03-03;Cinéma;11,50\n", + encoding="utf-8", +) + +total = 0 +with open(Path("depenses.csv"), "r", encoding="utf-8", newline="") as fichier: + lecteur = csv.reader(fichier, delimiter=";") + next(lecteur) + for date, libelle, montant in lecteur: + valeur = float(montant.replace(",", ".")) # 54,20 → 54.20 + total += valeur + print(f"{date} {libelle:<10} {valeur:>7.2f}") + +print(f"Total : {total:.2f} €") +``` + +```text +2024-03-01 Courses 54.20 +2024-03-03 Cinéma 11.50 +Total : 65.70 € +``` + +Note le déballage `for date, libelle, montant in lecteur` et la conversion de la virgule décimale en point, indispensable avant `float()`. + +#### Lire avec `csv.DictReader` : accéder aux colonnes par leur nom + +Accéder aux colonnes par indice (`ligne[1]`) n'est pas très lisible, et casse dès qu'on ajoute une colonne. `csv.DictReader` utilise la ligne d'en-têtes pour transformer chaque ligne en **dictionnaire** : + +```python +import csv +from pathlib import Path + +with open(Path("eleves.csv"), "r", encoding="utf-8", newline="") as fichier: + lecteur = csv.DictReader(fichier) + for eleve in lecteur: + print(eleve) +``` + +```text +{'nom': 'Alice', 'note': '15.5', 'classe': '3A'} +{'nom': 'Dupont, Jean', 'note': '12', 'classe': '3B'} +{'nom': 'Chloé', 'note': '18', 'classe': '3A'} +``` + +C'est beaucoup plus parlant : `eleve["note"]` au lieu de `eleve[1]`. La ligne d'en-têtes est consommée automatiquement. Et on retrouve la liste de dictionnaires du chapitre 04 : + +```python +import csv +from pathlib import Path + +with open(Path("eleves.csv"), "r", encoding="utf-8", newline="") as fichier: + eleves = list(csv.DictReader(fichier)) + +classe_3a = [e["nom"] for e in eleves if e["classe"] == "3A"] +meilleur = max(eleves, key=lambda e: float(e["note"])) +print(classe_3a) # → ['Alice', 'Chloé'] +print(meilleur["nom"]) # → Chloé +``` + +#### Écrire avec `csv.DictWriter` + +Symétriquement, `csv.DictWriter` écrit une liste de dictionnaires. On lui indique la liste des colonnes avec `fieldnames`, et on écrit la ligne d'en-têtes avec `writeheader()` : + +```python +import csv +from pathlib import Path + +produits = [ + {"nom": "Stylo", "prix": 1.5, "stock": 120}, + {"nom": "Cahier", "prix": 3.2, "stock": 45}, + {"nom": "Gomme", "prix": 0.8, "stock": 0}, +] + +colonnes = ["nom", "prix", "stock"] +with open(Path("produits.csv"), "w", encoding="utf-8", newline="") as fichier: + ecrivain = csv.DictWriter(fichier, fieldnames=colonnes, delimiter=";") + ecrivain.writeheader() + ecrivain.writerows(produits) + +print(Path("produits.csv").read_text(encoding="utf-8"), end="") +``` + +```text +nom;prix;stock +Stylo;1.5;120 +Cahier;3.2;45 +Gomme;0.8;0 +``` + +- L'ordre des colonnes dans le fichier est celui de `fieldnames`. +- Si un dictionnaire contient une clé absente de `fieldnames`, `DictWriter` provoque une erreur `ValueError` (on peut ajouter `extrasaction="ignore"` pour l'ignorer). +- Si une clé manque dans un dictionnaire, la case reste vide. + +#### Ajouter des lignes à un CSV existant + +On combine le mode `"a"` et `DictWriter`, **sans** réécrire les en-têtes : + +```python +import csv +from pathlib import Path + +nouveau = {"nom": "Règle", "prix": 2.1, "stock": 30} +with open(Path("produits.csv"), "a", encoding="utf-8", newline="") as fichier: + ecrivain = csv.DictWriter(fichier, fieldnames=["nom", "prix", "stock"], delimiter=";") + ecrivain.writerow(nouveau) + +print(Path("produits.csv").read_text(encoding="utf-8"), end="") +``` + +```text +nom;prix;stock +Stylo;1.5;120 +Cahier;3.2;45 +Gomme;0.8;0 +Règle;2.1;30 +``` + +> **Et Excel ?** Excel a parfois du mal à détecter l'UTF-8 et affiche alors des caractères bizarres. Si un fichier doit être ouvert par Excel, utilise `encoding="utf-8-sig"` à l'écriture : cela ajoute une petite signature invisible au début du fichier que Excel reconnaît. Python relit sans problème ce fichier avec `encoding="utf-8-sig"`. + +--- + +### 7. Les fichiers JSON + +#### Le format JSON + +**JSON** (*JavaScript Object Notation*) est un format texte pour représenter des **données structurées** : des listes et des dictionnaires imbriqués. C'est la langue commune des applications web et de très nombreux fichiers de configuration. Bonne nouvelle : il ressemble énormément à la syntaxe Python ! + +```json +{ + "nom": "Alice", + "age": 30, + "abonne": true, + "telephone": null, + "loisirs": ["lecture", "escalade"], + "adresse": {"ville": "Lyon", "code_postal": "69001"} +} +``` + +Correspondance entre JSON et Python : + +| JSON | Python | +|---|---| +| objet `{...}` | `dict` | +| tableau `[...]` | `list` | +| chaîne `"..."` (guillemets **doubles** obligatoires) | `str` | +| nombre `42`, `3.14` | `int`, `float` | +| `true` / `false` | `True` / `False` | +| `null` | `None` | + +Contrairement au CSV, qui ne stocke que des tableaux de textes, le JSON conserve la **structure** et les **types** (nombres, booléens, listes imbriquées…). C'est le format idéal pour sauvegarder les données d'un programme. + +#### Sauvegarder avec `json.dump` + +```python +import json +from pathlib import Path + +contact = { + "nom": "Chloé", + "age": 28, + "abonne": True, + "telephone": None, + "loisirs": ["piano", "randonnée"], +} + +with open(Path("contact.json"), "w", encoding="utf-8") as fichier: + json.dump(contact, fichier, ensure_ascii=False, indent=4) + +print(Path("contact.json").read_text(encoding="utf-8")) +``` + +```text +{ + "nom": "Chloé", + "age": 28, + "abonne": true, + "telephone": null, + "loisirs": [ + "piano", + "randonnée" + ] +} +``` + +`json.dump(donnees, fichier, ...)` convertit les données Python en texte JSON et les écrit dans le fichier. Deux options à utiliser **systématiquement** : + +- **`indent=4`** : met en forme le JSON sur plusieurs lignes avec une indentation, pour qu'il soit lisible par un humain. Sans cela, tout est écrit sur une seule ligne. +- **`ensure_ascii=False`** : écrit les caractères accentués tels quels. Sans cette option, Python remplace chaque caractère non-ASCII par un code, comme `\u00e9` pour `é` : le fichier reste valide, mais devient illisible pour un humain. + +Compare : + +```python +import json + +print(json.dumps({"ville": "Besançon"})) +print(json.dumps({"ville": "Besançon"}, ensure_ascii=False)) +``` + +```text +{"ville": "Besan\u00e7on"} +{"ville": "Besançon"} +``` + +(`json.dumps`, avec un **s** pour *string*, renvoie le JSON sous forme de chaîne au lieu de l'écrire dans un fichier.) + +#### Charger avec `json.load` + +```python +import json +from pathlib import Path + +with open(Path("contact.json"), "r", encoding="utf-8") as fichier: + contact = json.load(fichier) + +print(type(contact)) # → +print(contact["nom"]) # → Chloé +print(contact["loisirs"]) # → ['piano', 'randonnée'] +print(contact["abonne"]) # → True +print(contact["telephone"]) # → None +``` + +`json.load(fichier)` lit le fichier et reconstruit les objets Python : on retrouve un vrai dictionnaire, avec de vrais booléens, de vraies listes, de vrais nombres. Aucune conversion manuelle n'est nécessaire, contrairement au CSV. + +Il existe aussi `json.loads(chaine)` (avec un **s**), qui analyse une chaîne JSON : + +```python +import json + +donnees = json.loads('{"scores": [12, 18, 15], "joueur": "Léo"}') +print(donnees["joueur"], max(donnees["scores"])) # → Léo 18 +``` + +Pour retenir : **`dump`** = de Python **vers** le JSON (on « vide » ses données dans le fichier), **`load`** = du JSON **vers** Python (on « charge »). Avec un `s` final, on travaille sur une chaîne au lieu d'un fichier. + +#### Sauvegarder toute une structure + +Le JSON gère les structures imbriquées : une liste de dictionnaires, un dictionnaire de listes… Voici le schéma typique d'un programme qui conserve ses données entre deux exécutions : + +```python +import json +from pathlib import Path + +FICHIER_SCORES = Path("scores.json") + + +def charger_scores(chemin): + """Renvoie le dictionnaire des scores, ou un dictionnaire vide si le fichier n'existe pas.""" + if not chemin.exists(): + return {} + with open(chemin, "r", encoding="utf-8") as fichier: + return json.load(fichier) + + +def sauvegarder_scores(scores, chemin): + """Écrit le dictionnaire des scores dans le fichier JSON.""" + with open(chemin, "w", encoding="utf-8") as fichier: + json.dump(scores, fichier, ensure_ascii=False, indent=2) + + +scores = charger_scores(FICHIER_SCORES) +print(f"Scores chargés : {scores}") + +scores["Inès"] = scores.get("Inès", 0) + 150 +scores["Noé"] = scores.get("Noé", 0) + 90 +sauvegarder_scores(scores, FICHIER_SCORES) + +print(f"Scores sauvegardés : {charger_scores(FICHIER_SCORES)}") +``` + +Première exécution : + +```text +Scores chargés : {} +Scores sauvegardés : {'Inès': 150, 'Noé': 90} +``` + +Si tu relances le programme, les scores sont retrouvés et augmentés : + +```text +Scores chargés : {'Inès': 150, 'Noé': 90} +Scores sauvegardés : {'Inès': 300, 'Noé': 180} +``` + +Ton programme a désormais une **mémoire** ! Retiens bien ce couple de fonctions `charger` / `sauvegarder` : tu le réutiliseras dans presque tous tes projets. + +#### Ce que JSON ne sait pas stocker + +JSON ne connaît que les types du tableau ci-dessus. Quelques conséquences : + +- Les **tuples** deviennent des listes : `(1, 2)` est relu comme `[1, 2]`. +- Les **ensembles** (`set`) ne sont pas acceptés : convertis-les en liste avant (`list(mon_ensemble)`). +- Les **clés** de dictionnaire deviennent toujours des chaînes (voir les pièges). +- Les dates et les objets que tu créeras plus tard (chapitres 09 et 10) doivent être convertis à la main (en chaîne ou en dictionnaire). + +```python +import json + +donnees = {"point": (3, 4), "tags": sorted({"b", "a"})} +texte = json.dumps(donnees) +print(texte) # → {"point": [3, 4], "tags": ["a", "b"]} +print(json.loads(texte)) # → {'point': [3, 4], 'tags': ['a', 'b']} +``` + +Essayer d'enregistrer un ensemble directement provoque une erreur : + +```python +import json + +json.dumps({"tags": {"python", "json"}}) +``` + +```text +Traceback (most recent call last): + File "", line 3, in +TypeError: Object of type set is not JSON serializable +``` + +--- + +### 8. CSV ou JSON : lequel choisir ? + +| Critère | CSV | JSON | +|---|---|---| +| Structure | un tableau plat (lignes × colonnes) | arbitraire : listes et dictionnaires imbriqués | +| Types | tout est texte, il faut convertir | nombres, booléens, `null` conservés | +| Lisible par un tableur | oui (Excel, LibreOffice) | non | +| Usage typique | exports, données tabulaires, échanges avec des non-programmeurs | sauvegarde de l'état d'un programme, configuration, web (API) | + +En pratique : si tes données ressemblent à un tableau et doivent être ouvertes dans un tableur, choisis le **CSV**. Si elles sont structurées (un contact avec plusieurs téléphones, des paramètres imbriqués), ou s'il s'agit simplement de sauvegarder les variables de ton programme, choisis le **JSON**. + +On peut d'ailleurs passer facilement de l'un à l'autre : `csv.DictReader` produit une liste de dictionnaires, que `json.dump` sait écrire, et inversement (c'est l'exercice 7). + +--- + +## Pièges fréquents + +### Piège 1 — Oublier `encoding="utf-8"` + +```python +from pathlib import Path + +Path("accents.txt").write_text("Crème brûlée\n", encoding="utf-8") +with open(Path("accents.txt"), "r", encoding="ascii") as fichier: # mauvais encodage + print(fichier.read()) +``` + +```text +Traceback (most recent call last): + File "", line 5, in +UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 2: ordinal not in range(128) +``` + +Selon le système et l'encodage utilisé, on obtient soit une `UnicodeDecodeError` comme ici, soit des caractères bizarres (`Crème brûlée`). Si tu n'indiques pas d'encodage, Python utilise celui de ton système, et ton programme peut fonctionner chez toi mais pas chez quelqu'un d'autre. **Correction :** toujours `encoding="utf-8"`, en lecture **et** en écriture. + +### Piège 2 — Écraser un fichier avec le mode `"w"` + +```python +from pathlib import Path + +with open(Path("journal.txt"), "w", encoding="utf-8") as fichier: + fichier.write("Nouvelle entrée\n") + +print(Path("journal.txt").read_text(encoding="utf-8"), end="") # → Nouvelle entrée +``` + +Toutes les entrées précédentes du journal ont disparu, sans aucun avertissement. **Correction :** utiliser le mode `"a"` pour **ajouter**. En cas de doute, vérifie avec `exists()` avant d'écrire, ou utilise `"x"` qui refuse d'écraser un fichier existant. + +### Piège 3 — Oublier les `\n` avec `write` + +```python +from pathlib import Path + +with open(Path("courses.txt"), "w", encoding="utf-8") as fichier: + for article in ["pain", "lait", "oeufs"]: + fichier.write(article) + +print(Path("courses.txt").read_text(encoding="utf-8")) # → painlaitoeufs +``` + +**Correction :** `fichier.write(article + "\n")`, ou `print(article, file=fichier)`. + +### Piège 4 — Oublier de retirer les `\n` à la lecture + +```python +from pathlib import Path + +Path("mdp.txt").write_text("secret123\n", encoding="utf-8") +with open(Path("mdp.txt"), "r", encoding="utf-8") as fichier: + mot_de_passe = fichier.readline() + +print(mot_de_passe == "secret123") # → False +print(repr(mot_de_passe)) # → 'secret123\n' +``` + +Le `\n` fait partie de la ligne lue ! **Correction :** `fichier.readline().strip()`. Quand une comparaison échoue mystérieusement, affiche la valeur avec `repr()` pour voir les caractères invisibles. + +### Piège 5 — Calculer avec des valeurs CSV sans les convertir + +```python +import csv +from pathlib import Path + +Path("ventes.csv").write_text("produit,quantite\nA,3\nB,5\n", encoding="utf-8") +with open(Path("ventes.csv"), "r", encoding="utf-8", newline="") as fichier: + total = 0 + for ligne in csv.DictReader(fichier): + total += ligne["quantite"] +``` + +```text +Traceback (most recent call last): + File "", line 7, in +TypeError: unsupported operand type(s) for +=: 'int' and 'str' +``` + +Le module `csv` renvoie **toujours** des chaînes. **Correction :** `total += int(ligne["quantite"])`. + +### Piège 6 — Oublier `newline=""` avec le module `csv` + +Sous Linux et macOS, on ne voit pas la différence. Mais sous Windows, sans `newline=""`, chaque ligne écrite par `csv.writer` est suivie d'une **ligne vide** quand on ouvre le fichier dans un tableur. **Correction :** ouvre **toujours** un fichier CSV avec `open(chemin, mode, encoding="utf-8", newline="")`, en lecture comme en écriture. + +### Piège 7 — Lire deux fois le même fichier ouvert + +```python +from pathlib import Path + +with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier: + premiere_lecture = fichier.read() + deuxieme_lecture = fichier.read() + +print(len(premiere_lecture)) # → 20 +print(repr(deuxieme_lecture)) # → '' +``` + +Un fichier ouvert a un **curseur**, comme une tête de lecture : après le premier `read()`, il est à la fin et il n'y a plus rien à lire. Même chose si tu boucles deux fois sur un fichier ou un `csv.reader`. **Correction :** stocke le contenu dans une variable (une liste, par exemple) et réutilise-la, ou rouvre le fichier. + +### Piège 8 — Les clés JSON deviennent des chaînes + +```python +import json + +niveaux = {1: "facile", 2: "moyen"} +relu = json.loads(json.dumps(niveaux)) +print(relu) # → {'1': 'facile', '2': 'moyen'} +print(relu.get(1)) # → None +``` + +En JSON, les clés d'un objet sont **toujours** des chaînes. Après un aller-retour, `1` est devenu `"1"`, et `relu[1]` provoquerait une `KeyError`. **Correction :** utiliser des chaînes comme clés dès le départ, ou reconvertir après chargement : `{int(cle): valeur for cle, valeur in relu.items()}`. + +--- + +## Exercices + +> Pour chaque exercice, le corrigé commence par créer les fichiers de données nécessaires : tu peux donc les tester dans un dossier vide. + +### Exercice 1 — Écrire puis relire ⭐ + +1. Écris dans `table7.txt` la table de multiplication de 7, de 1 à 10, une opération par ligne (`7 x 1 = 7`). +2. Relis le fichier et affiche seulement les lignes dont le résultat est pair, précédées de leur numéro de ligne. + +Sortie attendue : + +```text +Ligne 2 : 7 x 2 = 14 +Ligne 4 : 7 x 4 = 28 +Ligne 6 : 7 x 6 = 42 +Ligne 8 : 7 x 8 = 56 +Ligne 10 : 7 x 10 = 70 +``` + +### Exercice 2 — Compteur de fichier ⭐ + +Écris une fonction `statistiques_fichier(chemin)` qui renvoie un dictionnaire avec le nombre de lignes, de mots et de caractères d'un fichier texte (comme la commande `wc` de Linux), en comptant les caractères **sans** les retours à la ligne. Teste-la sur un fichier `poeme.txt` contenant : + +```text +Demain, dès l'aube, à l'heure où blanchit la campagne, +Je partirai. Vois-tu, je sais que tu m'attends. +J'irai par la forêt, j'irai par la montagne. +``` + +Sortie attendue : + +```text +{'lignes': 3, 'mots': 25, 'caracteres': 145} +``` + +### Exercice 3 — Nettoyer un fichier de configuration ⭐⭐ + +Un fichier `config.txt` contient des réglages `cle = valeur`, des lignes vides et des commentaires commençant par `#` : + +```text +# Configuration du jeu +largeur = 800 +hauteur=600 + +# Son +volume = 75 +pseudo = Joueur 1 +``` + +Écris une fonction `lire_config(chemin)` qui renvoie un dictionnaire des réglages, en ignorant les commentaires et les lignes vides, et en nettoyant les espaces autour des clés et des valeurs. Indice : `ligne.split("=", 1)` coupe uniquement au premier `=`. + +```python +print(lire_config(Path("config.txt"))) +``` + +```text +{'largeur': '800', 'hauteur': '600', 'volume': '75', 'pseudo': 'Joueur 1'} +``` + +### Exercice 4 — Bulletin à partir d'un CSV ⭐⭐ + +Le fichier `notes.csv` (séparateur `;`) contient une ligne par élève : + +```text +nom;maths;francais;anglais +Alice;15;12;17 +Bob;9;11;8 +Chloé;18;16;14 +David;10;9;12 +``` + +Avec `csv.DictReader` et `csv.DictWriter`, crée un fichier `bulletin.csv` (séparateur `;`) avec les colonnes `nom`, `moyenne` (arrondie à 2 décimales) et `resultat` (`admis` si moyenne ≥ 10, sinon `ajourné`), trié par moyenne décroissante. Affiche ensuite son contenu. + +Sortie attendue : + +```text +nom;moyenne;resultat +Chloé;16.0;admis +Alice;14.67;admis +David;10.33;admis +Bob;9.33;ajourné +``` + +### Exercice 5 — Préférences en JSON ⭐⭐ + +Écris un programme qui gère un fichier `preferences.json` : +- une constante `PREFERENCES_PAR_DEFAUT = {"theme": "clair", "langue": "fr", "taille_police": 12}` ; +- une fonction `charger_preferences(chemin)` qui renvoie les préférences du fichier s'il existe, **complétées** par les valeurs par défaut pour les clés manquantes ; si le fichier n'existe pas, elle renvoie une copie des valeurs par défaut ; +- une fonction `sauvegarder_preferences(preferences, chemin)` (JSON lisible, accents conservés). + +Teste : supprime le fichier s'il existe (`chemin.unlink(missing_ok=True)`), charge, passe le thème à `"sombre"`, sauvegarde ; écris ensuite à la main un fichier ne contenant que `{"langue": "en"}` et recharge. + +```text +Au départ : {'theme': 'clair', 'langue': 'fr', 'taille_police': 12} +Après sauvegarde : {'theme': 'sombre', 'langue': 'fr', 'taille_police': 12} +Fichier partiel : {'theme': 'clair', 'langue': 'en', 'taille_police': 12} +``` + +### Exercice 6 — Inventaire d'un dossier ⭐⭐ + +Crée un dossier `inventaire` contenant les fichiers `rapport.txt`, `photo.jpg`, `budget.csv`, `notes.txt` et `vacances.jpg` (contenu au choix, par exemple leur propre nom). Écris une fonction `inventaire_par_extension(dossier)` qui renvoie un dictionnaire `{extension: [noms de fichiers triés]}`, puis affiche-le par extension triée. + +Sortie attendue : + +```text +.csv : budget.csv +.jpg : photo.jpg, vacances.jpg +.txt : notes.txt, rapport.txt +``` + +### Exercice 7 — Convertisseur CSV ↔ JSON ⭐⭐⭐ + +Écris deux fonctions : +- `csv_vers_json(chemin_csv, chemin_json)` : lit un CSV (séparateur `,`) avec `DictReader` et écrit la liste de dictionnaires en JSON. Les valeurs qui ne contiennent que des chiffres doivent être converties en `int` ; +- `json_vers_csv(chemin_json, chemin_csv)` : fait l'inverse avec `DictWriter`, en prenant comme colonnes les clés du premier élément. + +Teste avec un fichier `villes.csv` : + +```text +ville,departement,habitants +Lyon,Rhône,522250 +Lille,Nord,236710 +Nantes,Loire-Atlantique,320732 +``` + +Puis reconvertis le JSON en `villes_copie.csv` et vérifie que le contenu est identique à l'original. + +Sortie attendue : + +```text +[ + { + "ville": "Lyon", + "departement": "Rhône", + "habitants": 522250 + }, + { + "ville": "Lille", + "departement": "Nord", + "habitants": 236710 + }, + { + "ville": "Nantes", + "departement": "Loire-Atlantique", + "habitants": 320732 + } +] +Total habitants : 1079692 +Copie identique : True +``` + +### Exercice 8 — Rapport de ventes ⭐⭐⭐ + +Le fichier `ventes.csv` liste des ventes : + +```text +date,produit,categorie,quantite,prix_unitaire +2024-05-02,Clavier,Informatique,2,49.90 +2024-05-02,Stylo,Papeterie,10,1.20 +2024-05-03,Souris,Informatique,3,19.90 +2024-05-03,Cahier,Papeterie,5,3.50 +2024-05-04,Clavier,Informatique,1,49.90 +2024-05-04,Casque,Audio,1,89.00 +``` + +Écris un programme qui : +1. calcule le chiffre d'affaires (quantité × prix) **par catégorie** et le nombre d'articles vendus par catégorie ; +2. écrit `rapport_ventes.csv` avec les colonnes `categorie`, `articles`, `chiffre_affaires` (2 décimales), trié par chiffre d'affaires décroissant ; +3. écrit `rapport_ventes.json` contenant `{"total": ..., "meilleur_produit": ..., "categories": {...}}`, où `meilleur_produit` est le produit au plus gros chiffre d'affaires cumulé ; +4. affiche le contenu des deux fichiers. + +Sortie attendue : + +```text +categorie,articles,chiffre_affaires +Informatique,6,209.40 +Audio,1,89.00 +Papeterie,15,29.50 +{ + "total": 327.9, + "meilleur_produit": "Clavier", + "categories": { + "Informatique": 209.4, + "Papeterie": 29.5, + "Audio": 89.0 + } +} +``` + +--- + +## Mini-projet — Le carnet de contacts sauvegardé en JSON + +### Cahier des charges + +Écris un carnet de contacts en ligne de commande, dont les données **survivent** à la fermeture du programme grâce à un fichier `contacts.json`. + +Chaque contact est un dictionnaire : + +```json +{"nom": "Martin", "prenom": "Alice", "telephone": "06 12 34 56 78", "email": "alice@exemple.fr"} +``` + +Le carnet entier est une **liste** de ces dictionnaires. Le programme affiche un menu en boucle : + +```text +=== CARNET DE CONTACTS (2 contacts) === +1. Lister les contacts +2. Ajouter un contact +3. Rechercher +4. Supprimer un contact +5. Exporter en CSV +6. Quitter +``` + +- **Au démarrage**, les contacts sont chargés depuis `contacts.json` (liste vide si le fichier n'existe pas). +- **Lister** : affiche les contacts triés par nom puis prénom, numérotés, dans un tableau aligné. +- **Ajouter** : demande nom, prénom, téléphone et email ; nom et prénom sont obligatoires (on redemande s'ils sont vides). Le nom est mis en majuscules, le prénom avec une majuscule initiale. **Sauvegarde immédiate.** +- **Rechercher** : demande un texte et affiche les contacts dont le nom, le prénom ou l'email le contient (sans tenir compte de la casse). +- **Supprimer** : affiche la liste numérotée, demande un numéro, demande confirmation (`o/n`), supprime et **sauvegarde**. +- **Exporter** : écrit tous les contacts dans `contacts.csv` (séparateur `;`, encodage `utf-8-sig` pour Excel) avec `DictWriter`. +- **Quitter** : message d'au revoir. + +### Étapes suggérées + +1. Écris `charger_contacts(chemin)` et `sauvegarder_contacts(contacts, chemin)` (sur le modèle des scores de la section 7) et teste-les seules. +2. Écris `afficher_contacts(contacts)` avec un tableau aligné par f-strings et `sorted(..., key=lambda c: (c["nom"], c["prenom"]))`. +3. Écris `demander_non_vide(message)` puis `ajouter_contact(contacts)`. +4. Écris `rechercher(contacts, texte)` qui **renvoie** la liste des résultats (sans `print`). +5. Écris `supprimer_contact(contacts)` en vérifiant que le numéro saisi est valide (`isdigit()` et dans les bornes). +6. Écris `exporter_csv(contacts, chemin)`. +7. Assemble le tout dans `main()` avec le menu. + +### Pistes d'amélioration + +- **Modifier** un contact existant. +- Refuser les **doublons** (même nom et même prénom). +- Vérifier le format de l'email (présence d'un `@` et d'un `.` après) et du téléphone (10 chiffres). +- Permettre **plusieurs numéros** par contact (une liste dans le dictionnaire : impossible en CSV simple, facile en JSON !). +- **Importer** des contacts depuis un CSV. +- Faire une **sauvegarde de sécurité** `contacts.bak.json` avant chaque écriture. +- Gérer un fichier JSON corrompu avec `try/except` ([chapitre 08](08-erreurs-et-debogage.md)), puis transformer les contacts en objets ([chapitre 10](10-poo-classes-objets.md)). + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +```python +from pathlib import Path + +chemin = Path("table7.txt") + +# 1. Écriture +with open(chemin, "w", encoding="utf-8") as fichier: + for i in range(1, 11): + fichier.write(f"7 x {i} = {7 * i}\n") + +# 2. Relecture et filtrage +with open(chemin, "r", encoding="utf-8") as fichier: + for numero, ligne in enumerate(fichier, start=1): + ligne = ligne.strip() + resultat = int(ligne.split("=")[1]) + if resultat % 2 == 0: + print(f"Ligne {numero} : {ligne}") +``` + +Sortie : + +```text +Ligne 2 : 7 x 2 = 14 +Ligne 4 : 7 x 4 = 28 +Ligne 6 : 7 x 6 = 42 +Ligne 8 : 7 x 8 = 56 +Ligne 10 : 7 x 10 = 70 +``` + +À l'écriture, la f-string se termine par `\n` pour que chaque opération soit sur sa propre ligne. À la lecture, on boucle directement sur le fichier avec `enumerate` pour avoir le numéro de ligne, on retire le `\n` avec `strip()`, puis on récupère le résultat en coupant sur `=` : `"7 x 2 = 14".split("=")` donne `['7 x 2 ', ' 14']`, et `int()` accepte les espaces autour du nombre. + +
+ +
+Corrigé de l'exercice 2 + +```python +from pathlib import Path + + +def statistiques_fichier(chemin): + """Renvoie le nombre de lignes, de mots et de caractères (hors retours à la ligne).""" + nb_lignes = 0 + nb_mots = 0 + nb_caracteres = 0 + with open(chemin, "r", encoding="utf-8") as fichier: + for ligne in fichier: + ligne = ligne.rstrip("\n") + nb_lignes += 1 + nb_mots += len(ligne.split()) + nb_caracteres += len(ligne) + return {"lignes": nb_lignes, "mots": nb_mots, "caracteres": nb_caracteres} + + +poeme = Path("poeme.txt") +poeme.write_text( + "Demain, dès l'aube, à l'heure où blanchit la campagne,\n" + "Je partirai. Vois-tu, je sais que tu m'attends.\n" + "J'irai par la forêt, j'irai par la montagne.\n", + encoding="utf-8", +) + +print(statistiques_fichier(poeme)) +``` + +Sortie : + +```text +{'lignes': 3, 'mots': 25, 'caracteres': 145} +``` + +On lit ligne par ligne (efficace même pour un très gros fichier) et on met à jour trois compteurs. `rstrip("\n")` ne retire que le retour à la ligne, pas les éventuels espaces en fin de ligne, qui sont de vrais caractères. `split()` sans argument découpe sur les espaces : « l'aube, » compte pour un seul mot. Les accents comptent pour un caractère chacun, grâce au décodage UTF-8. + +
+ +
+Corrigé de l'exercice 3 + +```python +from pathlib import Path + + +def lire_config(chemin): + """Lit un fichier 'cle = valeur' et renvoie un dictionnaire des réglages.""" + reglages = {} + with open(chemin, "r", encoding="utf-8") as fichier: + for ligne in fichier: + ligne = ligne.strip() + if ligne == "" or ligne.startswith("#"): + continue # ligne vide ou commentaire + cle, valeur = ligne.split("=", 1) + reglages[cle.strip()] = valeur.strip() + return reglages + + +Path("config.txt").write_text( + "# Configuration du jeu\n" + "largeur = 800\n" + "hauteur=600\n" + "\n" + "# Son\n" + "volume = 75\n" + "pseudo = Joueur 1\n", + encoding="utf-8", +) + +print(lire_config(Path("config.txt"))) +``` + +Sortie : + +```text +{'largeur': '800', 'hauteur': '600', 'volume': '75', 'pseudo': 'Joueur 1'} +``` + +`continue` (chapitre 03) passe directement à la ligne suivante pour les lignes à ignorer. `split("=", 1)` coupe au **premier** `=` seulement : une valeur pourrait contenir un `=` (par exemple une formule). Les valeurs restent des chaînes ; c'est au programme qui les utilise de les convertir (`int(reglages["largeur"])`). Une ligne sans `=` provoquerait une erreur au déballage : on apprendra à la gérer proprement au chapitre 08. + +
+ +
+Corrigé de l'exercice 4 + +```python +import csv +from pathlib import Path + +Path("notes.csv").write_text( + "nom;maths;francais;anglais\n" + "Alice;15;12;17\n" + "Bob;9;11;8\n" + "Chloé;18;16;14\n" + "David;10;9;12\n", + encoding="utf-8", +) + +# Lecture et calcul +bulletin = [] +with open(Path("notes.csv"), "r", encoding="utf-8", newline="") as fichier: + for ligne in csv.DictReader(fichier, delimiter=";"): + notes = [int(ligne[matiere]) for matiere in ["maths", "francais", "anglais"]] + moyenne = round(sum(notes) / len(notes), 2) + bulletin.append({ + "nom": ligne["nom"], + "moyenne": moyenne, + "resultat": "admis" if moyenne >= 10 else "ajourné", + }) + +bulletin.sort(key=lambda eleve: eleve["moyenne"], reverse=True) + +# Écriture +with open(Path("bulletin.csv"), "w", encoding="utf-8", newline="") as fichier: + ecrivain = csv.DictWriter(fichier, fieldnames=["nom", "moyenne", "resultat"], delimiter=";") + ecrivain.writeheader() + ecrivain.writerows(bulletin) + +print(Path("bulletin.csv").read_text(encoding="utf-8"), end="") +``` + +Sortie : + +```text +nom;moyenne;resultat +Chloé;16.0;admis +Alice;14.67;admis +David;10.33;admis +Bob;9.33;ajourné +``` + +`DictReader` permet d'écrire `ligne["maths"]` plutôt que `ligne[1]`. Une compréhension convertit les trois notes en entiers. On construit une liste de dictionnaires (les lignes du futur fichier), qu'on trie avec une `lambda` avant de la donner à `DictWriter`. Le même `delimiter=";"` est utilisé en lecture et en écriture. + +
+ +
+Corrigé de l'exercice 5 + +```python +import json +from pathlib import Path + +PREFERENCES_PAR_DEFAUT = {"theme": "clair", "langue": "fr", "taille_police": 12} + + +def charger_preferences(chemin): + """Renvoie les préférences du fichier, complétées par les valeurs par défaut.""" + preferences = PREFERENCES_PAR_DEFAUT.copy() + if chemin.exists(): + with open(chemin, "r", encoding="utf-8") as fichier: + preferences.update(json.load(fichier)) + return preferences + + +def sauvegarder_preferences(preferences, chemin): + """Enregistre les préférences au format JSON lisible.""" + with open(chemin, "w", encoding="utf-8") as fichier: + json.dump(preferences, fichier, ensure_ascii=False, indent=4) + + +chemin = Path("preferences.json") +chemin.unlink(missing_ok=True) # on part d'une situation propre + +preferences = charger_preferences(chemin) +print(f"Au départ : {preferences}") + +preferences["theme"] = "sombre" +sauvegarder_preferences(preferences, chemin) +print(f"Après sauvegarde : {charger_preferences(chemin)}") + +chemin.write_text('{"langue": "en"}', encoding="utf-8") +print(f"Fichier partiel : {charger_preferences(chemin)}") +``` + +Sortie : + +```text +Au départ : {'theme': 'clair', 'langue': 'fr', 'taille_police': 12} +Après sauvegarde : {'theme': 'sombre', 'langue': 'fr', 'taille_police': 12} +Fichier partiel : {'theme': 'clair', 'langue': 'en', 'taille_police': 12} +``` + +L'astuce est de partir d'une **copie** des valeurs par défaut, puis de la mettre à jour avec `update()`, une méthode des dictionnaires qui ajoute ou remplace les paires d'un autre dictionnaire. Sans `.copy()`, on modifierait la constante elle-même (piège de la référence, chapitre 04). `unlink()` supprime un fichier ; `missing_ok=True` évite l'erreur s'il n'existe pas. + +
+ +
+Corrigé de l'exercice 6 + +```python +from pathlib import Path + + +def inventaire_par_extension(dossier): + """Renvoie {extension: [noms de fichiers triés]} pour les fichiers du dossier.""" + inventaire = {} + for chemin in dossier.iterdir(): + if chemin.is_file(): + extension = chemin.suffix + if extension not in inventaire: + inventaire[extension] = [] + inventaire[extension].append(chemin.name) + return {ext: sorted(noms) for ext, noms in inventaire.items()} + + +dossier = Path("inventaire") +dossier.mkdir(exist_ok=True) +for nom in ["rapport.txt", "photo.jpg", "budget.csv", "notes.txt", "vacances.jpg"]: + (dossier / nom).write_text(nom, encoding="utf-8") + +inventaire = inventaire_par_extension(dossier) +for extension in sorted(inventaire): + print(f"{extension} : {', '.join(inventaire[extension])}") +``` + +Sortie : + +```text +.csv : budget.csv +.jpg : photo.jpg, vacances.jpg +.txt : notes.txt, rapport.txt +``` + +`iterdir()` parcourt le contenu du dossier ; `is_file()` écarte les éventuels sous-dossiers. `suffix` donne l'extension et `name` le nom complet. Comme l'ordre de `iterdir()` n'est pas garanti, on trie les noms (compréhension de dictionnaire) et les extensions à l'affichage. C'est la base d'un script qui range automatiquement un dossier de téléchargements (cf. [chapitre 14](14-specialisations.md)). + +
+ +
+Corrigé de l'exercice 7 + +```python +import csv +import json +from pathlib import Path + + +def csv_vers_json(chemin_csv, chemin_json): + """Convertit un CSV en JSON (liste de dictionnaires), les nombres entiers étant convertis.""" + with open(chemin_csv, "r", encoding="utf-8", newline="") as fichier: + lignes = list(csv.DictReader(fichier)) + + donnees = [] + for ligne in lignes: + donnees.append({cle: int(valeur) if valeur.isdigit() else valeur + for cle, valeur in ligne.items()}) + + with open(chemin_json, "w", encoding="utf-8") as fichier: + json.dump(donnees, fichier, ensure_ascii=False, indent=2) + + +def json_vers_csv(chemin_json, chemin_csv): + """Convertit un JSON (liste de dictionnaires) en CSV.""" + with open(chemin_json, "r", encoding="utf-8") as fichier: + donnees = json.load(fichier) + + with open(chemin_csv, "w", encoding="utf-8", newline="") as fichier: + ecrivain = csv.DictWriter(fichier, fieldnames=list(donnees[0].keys()), lineterminator="\n") + ecrivain.writeheader() + ecrivain.writerows(donnees) + + +Path("villes.csv").write_text( + "ville,departement,habitants\n" + "Lyon,Rhône,522250\n" + "Lille,Nord,236710\n" + "Nantes,Loire-Atlantique,320732\n", + encoding="utf-8", +) + +csv_vers_json(Path("villes.csv"), Path("villes.json")) +print(Path("villes.json").read_text(encoding="utf-8")) + +with open(Path("villes.json"), "r", encoding="utf-8") as fichier: + villes = json.load(fichier) +print(f"Total habitants : {sum(v['habitants'] for v in villes)}") + +json_vers_csv(Path("villes.json"), Path("villes_copie.csv")) +original = Path("villes.csv").read_text(encoding="utf-8") +copie = Path("villes_copie.csv").read_text(encoding="utf-8") +print(f"Copie identique : {original == copie}") +``` + +Sortie : + +```text +[ + { + "ville": "Lyon", + "departement": "Rhône", + "habitants": 522250 + }, + { + "ville": "Lille", + "departement": "Nord", + "habitants": 236710 + }, + { + "ville": "Nantes", + "departement": "Loire-Atlantique", + "habitants": 320732 + } +] +Total habitants : 1079692 +Copie identique : True +``` + +La compréhension de dictionnaire avec `int(valeur) if valeur.isdigit() else valeur` convertit seulement les valeurs numériques : dans le JSON, `522250` apparaît sans guillemets, c'est un vrai nombre, et la somme fonctionne directement après rechargement. Pour l'inverse, les colonnes sont les clés du premier dictionnaire. + +Un détail : par défaut, `csv.writer` termine les lignes par `\r\n` (la convention officielle du format CSV). Pour obtenir un fichier **strictement** identique à l'original, qui utilise `\n`, on a précisé `lineterminator="\n"`. Sans cette option, le contenu serait le même pour un tableur, mais la comparaison de chaînes renverrait `False`. + +
+ +
+Corrigé de l'exercice 8 + +```python +import csv +import json +from pathlib import Path + +Path("ventes.csv").write_text( + "date,produit,categorie,quantite,prix_unitaire\n" + "2024-05-02,Clavier,Informatique,2,49.90\n" + "2024-05-02,Stylo,Papeterie,10,1.20\n" + "2024-05-03,Souris,Informatique,3,19.90\n" + "2024-05-03,Cahier,Papeterie,5,3.50\n" + "2024-05-04,Clavier,Informatique,1,49.90\n" + "2024-05-04,Casque,Audio,1,89.00\n", + encoding="utf-8", +) + +# 1. Agrégation +ca_categories = {} +articles_categories = {} +ca_produits = {} +with open(Path("ventes.csv"), "r", encoding="utf-8", newline="") as fichier: + for vente in csv.DictReader(fichier): + quantite = int(vente["quantite"]) + montant = quantite * float(vente["prix_unitaire"]) + categorie = vente["categorie"] + produit = vente["produit"] + ca_categories[categorie] = ca_categories.get(categorie, 0) + montant + articles_categories[categorie] = articles_categories.get(categorie, 0) + quantite + ca_produits[produit] = ca_produits.get(produit, 0) + montant + +# 2. Rapport CSV, trié par chiffre d'affaires décroissant +categories_triees = sorted(ca_categories, key=lambda c: ca_categories[c], reverse=True) +with open(Path("rapport_ventes.csv"), "w", encoding="utf-8", newline="") as fichier: + ecrivain = csv.DictWriter(fichier, fieldnames=["categorie", "articles", "chiffre_affaires"], + lineterminator="\n") + ecrivain.writeheader() + for categorie in categories_triees: + ecrivain.writerow({ + "categorie": categorie, + "articles": articles_categories[categorie], + "chiffre_affaires": f"{ca_categories[categorie]:.2f}", + }) + +# 3. Rapport JSON +rapport = { + "total": round(sum(ca_categories.values()), 2), + "meilleur_produit": max(ca_produits, key=lambda p: ca_produits[p]), + "categories": {c: round(montant, 2) for c, montant in ca_categories.items()}, +} +with open(Path("rapport_ventes.json"), "w", encoding="utf-8") as fichier: + json.dump(rapport, fichier, ensure_ascii=False, indent=2) + +# 4. Affichage +print(Path("rapport_ventes.csv").read_text(encoding="utf-8"), end="") +print(Path("rapport_ventes.json").read_text(encoding="utf-8")) +``` + +Sortie : + +```text +categorie,articles,chiffre_affaires +Informatique,6,209.40 +Audio,1,89.00 +Papeterie,15,29.50 +{ + "total": 327.9, + "meilleur_produit": "Clavier", + "categories": { + "Informatique": 209.4, + "Papeterie": 29.5, + "Audio": 89.0 + } +} +``` + +On réutilise le schéma « compter avec un dictionnaire et `get` » (chapitre 04), cette fois pour additionner des montants. Trois dictionnaires d'agrégation suffisent. Dans le CSV, le chiffre d'affaires est formaté avec `:.2f` pour toujours afficher 2 décimales (`89.00`) ; dans le JSON, on garde de vrais nombres arrondis avec `round` (le JSON n'affiche pas les zéros inutiles). `round` est important : sans lui, les calculs en virgule flottante pourraient donner des valeurs comme `209.39999999999998`. + +
+ +
+Corrigé du mini-projet + +```python +import csv +import json +from pathlib import Path + +FICHIER_CONTACTS = Path("contacts.json") +FICHIER_EXPORT = Path("contacts.csv") +CHAMPS = ["nom", "prenom", "telephone", "email"] + + +# ---------- Persistance ---------- + +def charger_contacts(chemin): + """Renvoie la liste des contacts du fichier JSON (liste vide s'il n'existe pas).""" + if not chemin.exists(): + return [] + with open(chemin, "r", encoding="utf-8") as fichier: + return json.load(fichier) + + +def sauvegarder_contacts(contacts, chemin): + """Enregistre la liste des contacts au format JSON lisible.""" + with open(chemin, "w", encoding="utf-8") as fichier: + json.dump(contacts, fichier, ensure_ascii=False, indent=2) + + +def exporter_csv(contacts, chemin): + """Exporte les contacts dans un CSV lisible par Excel.""" + with open(chemin, "w", encoding="utf-8-sig", newline="") as fichier: + ecrivain = csv.DictWriter(fichier, fieldnames=CHAMPS, delimiter=";") + ecrivain.writeheader() + ecrivain.writerows(trier(contacts)) + + +# ---------- Traitements (sans input ni print) ---------- + +def trier(contacts): + """Renvoie une copie de la liste triée par nom puis prénom.""" + return sorted(contacts, key=lambda c: (c["nom"], c["prenom"])) + + +def rechercher(contacts, texte): + """Renvoie les contacts dont le nom, le prénom ou l'email contient texte.""" + texte = texte.lower() + return [c for c in trier(contacts) + if texte in c["nom"].lower() or texte in c["prenom"].lower() or texte in c["email"].lower()] + + +# ---------- Interface ---------- + +def afficher_contacts(contacts): + """Affiche les contacts dans un tableau numéroté.""" + if not contacts: + print("Aucun contact.") + return + print(f"{'N°':<4}{'Nom':<12}{'Prénom':<10}{'Téléphone':<16}Email") + for numero, contact in enumerate(contacts, start=1): + print(f"{numero:<4}{contact['nom']:<12}{contact['prenom']:<10}" + f"{contact['telephone']:<16}{contact['email']}") + + +def demander_non_vide(message): + """Redemande tant que la saisie est vide ; renvoie la saisie nettoyée.""" + saisie = input(message).strip() + while saisie == "": + print("Ce champ est obligatoire.") + saisie = input(message).strip() + return saisie + + +def ajouter_contact(contacts): + """Demande les informations d'un contact, l'ajoute et sauvegarde.""" + contact = { + "nom": demander_non_vide("Nom : ").upper(), + "prenom": demander_non_vide("Prénom : ").capitalize(), + "telephone": input("Téléphone : ").strip(), + "email": input("Email : ").strip().lower(), + } + contacts.append(contact) + sauvegarder_contacts(contacts, FICHIER_CONTACTS) + print(f"{contact['prenom']} {contact['nom']} ajouté(e) et sauvegardé(e).") + + +def supprimer_contact(contacts): + """Demande un numéro de contact, confirme, supprime et sauvegarde.""" + tries = trier(contacts) + afficher_contacts(tries) + if not tries: + return + saisie = input("Numéro à supprimer : ").strip() + if not saisie.isdigit() or not 1 <= int(saisie) <= len(tries): + print("Numéro invalide.") + return + contact = tries[int(saisie) - 1] + confirmation = input(f"Supprimer {contact['prenom']} {contact['nom']} ? (o/n) ").strip().lower() + if confirmation == "o": + contacts.remove(contact) + sauvegarder_contacts(contacts, FICHIER_CONTACTS) + print("Contact supprimé.") + else: + print("Suppression annulée.") + + +def main(): + """Boucle principale du carnet de contacts.""" + contacts = charger_contacts(FICHIER_CONTACTS) + while True: + print() + print(f"=== CARNET DE CONTACTS ({len(contacts)} contacts) ===") + print("1. Lister les contacts") + print("2. Ajouter un contact") + print("3. Rechercher") + print("4. Supprimer un contact") + print("5. Exporter en CSV") + print("6. Quitter") + choix = input("Ton choix : ").strip() + + if choix == "1": + afficher_contacts(trier(contacts)) + elif choix == "2": + ajouter_contact(contacts) + elif choix == "3": + texte = input("Rechercher : ").strip() + resultats = rechercher(contacts, texte) + print(f"{len(resultats)} résultat(s) :") + afficher_contacts(resultats) + elif choix == "4": + supprimer_contact(contacts) + elif choix == "5": + exporter_csv(contacts, FICHIER_EXPORT) + print(f"{len(contacts)} contact(s) exporté(s) dans {FICHIER_EXPORT}.") + elif choix == "6": + print("Au revoir !") + break + else: + print("Choix invalide.") + + +main() +``` + +Exemple de première session, en partant sans fichier `contacts.json` (saisies : `2`, `martin`, `alice`, `06 12 34 56 78`, `Alice@Exemple.fr`, `2`, `durand`, une ligne vide, `bob`, `07 98 76 54 32`, `bob@mail.fr`, `1`, `6`) : + +```text + +=== CARNET DE CONTACTS (0 contacts) === +1. Lister les contacts +2. Ajouter un contact +3. Rechercher +4. Supprimer un contact +5. Exporter en CSV +6. Quitter +Ton choix : 2 +Nom : martin +Prénom : alice +Téléphone : 06 12 34 56 78 +Email : Alice@Exemple.fr +Alice MARTIN ajouté(e) et sauvegardé(e). + +=== CARNET DE CONTACTS (1 contacts) === +1. Lister les contacts +2. Ajouter un contact +3. Rechercher +4. Supprimer un contact +5. Exporter en CSV +6. Quitter +Ton choix : 2 +Nom : durand +Prénom : +Ce champ est obligatoire. +Prénom : bob +Téléphone : 07 98 76 54 32 +Email : bob@mail.fr +Bob DURAND ajouté(e) et sauvegardé(e). + +=== CARNET DE CONTACTS (2 contacts) === +1. Lister les contacts +2. Ajouter un contact +3. Rechercher +4. Supprimer un contact +5. Exporter en CSV +6. Quitter +Ton choix : 1 +N° Nom Prénom Téléphone Email +1 DURAND Bob 07 98 76 54 32 bob@mail.fr +2 MARTIN Alice 06 12 34 56 78 alice@exemple.fr + +=== CARNET DE CONTACTS (2 contacts) === +1. Lister les contacts +2. Ajouter un contact +3. Rechercher +4. Supprimer un contact +5. Exporter en CSV +6. Quitter +Ton choix : 6 +Au revoir ! +``` + +Le fichier `contacts.json` contient alors : + +```json +[ + { + "nom": "MARTIN", + "prenom": "Alice", + "telephone": "06 12 34 56 78", + "email": "alice@exemple.fr" + }, + { + "nom": "DURAND", + "prenom": "Bob", + "telephone": "07 98 76 54 32", + "email": "bob@mail.fr" + } +] +``` + +Deuxième session, en relançant le programme (saisies : `3`, `ALI`, `4`, `1`, `o`, `5`, `6`) : les deux contacts ont bien été retrouvés. + +```text + +=== CARNET DE CONTACTS (2 contacts) === +1. Lister les contacts +2. Ajouter un contact +3. Rechercher +4. Supprimer un contact +5. Exporter en CSV +6. Quitter +Ton choix : 3 +Rechercher : ALI +1 résultat(s) : +N° Nom Prénom Téléphone Email +1 MARTIN Alice 06 12 34 56 78 alice@exemple.fr + +=== CARNET DE CONTACTS (2 contacts) === +1. Lister les contacts +2. Ajouter un contact +3. Rechercher +4. Supprimer un contact +5. Exporter en CSV +6. Quitter +Ton choix : 4 +N° Nom Prénom Téléphone Email +1 DURAND Bob 07 98 76 54 32 bob@mail.fr +2 MARTIN Alice 06 12 34 56 78 alice@exemple.fr +Numéro à supprimer : 1 +Supprimer Bob DURAND ? (o/n) o +Contact supprimé. + +=== CARNET DE CONTACTS (1 contacts) === +1. Lister les contacts +2. Ajouter un contact +3. Rechercher +4. Supprimer un contact +5. Exporter en CSV +6. Quitter +Ton choix : 5 +1 contact(s) exporté(s) dans contacts.csv. + +=== CARNET DE CONTACTS (1 contacts) === +1. Lister les contacts +2. Ajouter un contact +3. Rechercher +4. Supprimer un contact +5. Exporter en CSV +6. Quitter +Ton choix : 6 +Au revoir ! +``` + +**Points clés de la solution :** + +- Le code est organisé en trois couches : **persistance** (fichiers), **traitements** (calculs purs, sans `input` ni `print`), **interface** (dialogue avec l'utilisateur). Cette séparation facilitera les tests ([chapitre 13](13-tests-pytest-bonnes-pratiques.md)) et un éventuel passage à une interface web ([chapitre 14](14-specialisations.md)). +- Le fichier est **sauvegardé après chaque modification** : même si le programme est fermé brutalement, rien n'est perdu. +- `charger_contacts` gère l'absence de fichier au premier lancement grâce à `exists()`. +- `ensure_ascii=False` et `indent=2` rendent `contacts.json` lisible et modifiable à la main. +- La suppression se fait sur la liste **triée** affichée (pour que le numéro corresponde à ce que voit l'utilisateur), puis `contacts.remove(contact)` retire ce même dictionnaire de la liste d'origine : les deux listes contiennent des références vers les mêmes dictionnaires (chapitre 04). +- `not 1 <= int(saisie) <= len(tries)` vérifie les bornes ; grâce à l'évaluation paresseuse de `or`, `int(saisie)` n'est appelé que si `isdigit()` est vrai. +- L'export utilise `utf-8-sig` et `;` pour qu'Excel affiche correctement les accents et les colonnes. + +
+ +--- + +## Récapitulatif + +- Les fichiers rendent les données **persistantes** : elles survivent à la fin du programme. +- `pathlib.Path` manipule les chemins de façon portable : `/` pour assembler, `.name`, `.suffix`, `.parent`, `.exists()`, `.mkdir(parents=True, exist_ok=True)`, `.iterdir()`, `.glob("*.txt")`. `Path(__file__).parent` donne le dossier du script. +- On ouvre **toujours** un fichier avec `with open(chemin, mode, encoding="utf-8") as fichier:` ; il est fermé automatiquement à la sortie du bloc. +- Modes : `"r"` lecture (erreur si absent), `"w"` écriture (**écrase**), `"a"` ajout à la fin, `"x"` création exclusive. +- Lecture : `read()` (tout), `for ligne in fichier` (ligne par ligne, penser à `strip()`), `read().splitlines()` (liste propre). Raccourcis : `Path.read_text()` / `Path.write_text()`. +- Écriture : `write()` n'ajoute pas de `\n` ; `print(..., file=fichier)` si. +- CSV : `import csv`, ouvrir avec `newline=""` ; `csv.reader` / `csv.writer` (listes), `csv.DictReader` / `csv.DictWriter` (dictionnaires, avec `fieldnames` et `writeheader()`) ; `delimiter=";"` pour le format français. Toutes les valeurs lues sont des **chaînes**. +- JSON : `import json` ; `json.dump(donnees, fichier, ensure_ascii=False, indent=4)` pour écrire, `json.load(fichier)` pour lire ; `dumps`/`loads` pour les chaînes. Types conservés, mais clés toujours converties en chaînes, et pas d'ensembles. +- Schéma à retenir : une fonction `charger(chemin)` (avec valeur par défaut si le fichier n'existe pas) et une fonction `sauvegarder(donnees, chemin)`. + +### Checklist de validation + +- [ ] Je sais construire un chemin avec `Path` et l'opérateur `/`, et tester s'il existe. +- [ ] J'ouvre mes fichiers avec `with` et `encoding="utf-8"`, sans exception. +- [ ] Je connais la différence entre les modes `"r"`, `"w"` et `"a"`, et le danger de `"w"`. +- [ ] Je sais lire un fichier ligne par ligne en retirant les retours à la ligne. +- [ ] Je sais lire un CSV avec `DictReader` et convertir les valeurs en nombres. +- [ ] Je sais écrire un CSV avec `DictWriter` (avec `newline=""` et `writeheader()`). +- [ ] Je sais sauvegarder et recharger une liste de dictionnaires en JSON lisible et avec accents. +- [ ] Mon carnet de contacts retrouve ses données quand je le relance. + +--- + +[← Chapitre précédent](06-chaines-et-comprehensions.md) | [Sommaire](README.md) | [Chapitre suivant →](08-erreurs-et-debogage.md) diff --git a/docs/presentations/Python/08-erreurs-et-debogage.md b/docs/presentations/Python/08-erreurs-et-debogage.md new file mode 100644 index 0000000..abc12b8 --- /dev/null +++ b/docs/presentations/Python/08-erreurs-et-debogage.md @@ -0,0 +1,1694 @@ +# 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](07-fichiers-csv-json.md) (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. + +```python +# 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` : + +```python +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 + 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 (`` 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 + +```python +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 + +```python +"Â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 + +```python +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 + +```python +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 + +```python +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](07-fichiers-csv-json.md) sur les chemins). + +#### `ZeroDivisionError` — division par zéro + +```python +10 / 0 +# ZeroDivisionError: division by zero +``` + +Vaut aussi pour `//` et `%`. + +#### Deux autres très fréquentes + +```python +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` : + +```python +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 ». + +```python +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 : + +```python +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** : + +```python +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) : + +```python +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 : + +```python +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 : + +```python +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 : + +```python +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. + +```python +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…). + +```python +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](07-fichiers-csv-json.md)) 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. + +```python +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 + 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** : + +```python +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 : + +```python +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` : + +```python +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](10-poo-classes-objets.md) ; pour l'instant, retiens simplement cette **syntaxe à apprendre par cœur** : + +```python +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 : + +```python +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 : + +```python +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. + +```python +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é : + +```python +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. + +```python +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 : + +```python +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](01-variables-types-operateurs.md).) + +> ⚠️ 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** : + +```python +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` | **p**rint : affiche la valeur d'une expression | +| `n` | **n**ext : exécute la ligne courante et passe à la suivante (sans entrer dans les fonctions appelées) | +| `s` | **s**tep : entre **dans** la fonction appelée sur la ligne courante | +| `l` | **l**ist : affiche le code autour de la ligne courante | +| `c` | **c**ontinue : reprend l'exécution normale jusqu'au prochain `breakpoint()` | +| `q` | **q**uit : 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](00-installation-environnement.md)) 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 : + +```python +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 + 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 : + +```python +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](13-tests-pytest-bonnes-pratiques.md). + +> ⚠️ **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 + +```python +# ❌ À 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. + +```python +# ✅ 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 + +```python +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 + +```python +# ❌ 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. + +```python +# ✅ 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 + +```python +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` + +```python +# ❌ 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` + +```python +# ❌ 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 : + +```python +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) + +```python +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 + +```python +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`. + +```python +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 ? + +```python +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 + 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"`). + +```python +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 : + +```python +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. + +```python +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 : + - format incorrect (pas 3 éléments) ; + - nombre invalide ; + - opérateur inconnu ; + - division par zéro ; + - résultat trop grand (`10 ** 10000` en `float` → `OverflowError`). +8. **Ctrl+C** et **Ctrl+D** (fin de saisie) quittent proprement avec `Au revoir !`, sans traceback. +9. 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 + +```python +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 + +```python +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` : + +```python +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 + +```python +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 + +```python +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 + +```python +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 + +```python +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`.* + +```python +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 + +```python +"""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](07-fichiers-csv-json.md) | [Sommaire](README.md) | [Chapitre suivant →](09-modules-bibliotheque-standard.md) diff --git a/docs/presentations/Python/09-modules-bibliotheque-standard.md b/docs/presentations/Python/09-modules-bibliotheque-standard.md new file mode 100644 index 0000000..588e9ed --- /dev/null +++ b/docs/presentations/Python/09-modules-bibliotheque-standard.md @@ -0,0 +1,1777 @@ +# Chapitre 09 — Modules et bibliothèque standard + +> **Durée estimée** : 4 à 5 heures (cours + exercices + mini-projet) +> +> **Prérequis** : [Chapitre 08 — Erreurs et débogage](08-erreurs-et-debogage.md) (et en particulier les fonctions du [chapitre 05](05-fonctions.md)). +> +> **Objectifs** — À la fin de ce chapitre, tu sauras : +> - importer un module de plusieurs façons (`import`, `from … import`, `as`) et choisir la bonne ; +> - découper ton propre programme en plusieurs fichiers (modules) et en dossiers (paquets) ; +> - comprendre et utiliser `if __name__ == "__main__":` ; +> - utiliser les modules incontournables de la bibliothèque standard : `random`, `math`, `datetime`, `os`, `sys`, `collections` ; +> - explorer un module inconnu avec `dir()`, `help()` et la documentation officielle ; +> - éviter les pièges classiques (fichier nommé `random.py`, import circulaire…). + +--- + +## Cours + +### 1. Pourquoi des modules ? + +Jusqu'ici, tous tes programmes tenaient dans **un seul fichier**. C'est parfait pour 50 lignes. Mais un vrai programme en fait vite 500, 5 000 ou 50 000. Tout mettre dans un seul fichier, ce serait comme ranger toutes tes affaires dans un unique tiroir géant : on ne retrouve plus rien. + +Un **module**, c'est simplement **un fichier `.py`** qui contient des fonctions, des variables (et plus tard des classes) qu'on peut **réutiliser ailleurs**. Les modules servent à : + +- **organiser** : un fichier par thème (`calculs.py`, `affichage.py`, `sauvegarde.py`) ; +- **réutiliser** : écrire une fonction une fois, l'utiliser dans dix programmes ; +- **profiter du travail des autres** : Python est livré avec des centaines de modules prêts à l'emploi. + +Cette collection de modules livrés avec Python s'appelle la **bibliothèque standard**. On dit souvent que Python est livré « piles incluses » (*batteries included*) : pas besoin d'installer quoi que ce soit pour tirer des nombres au hasard, manipuler des dates, lire du JSON ou calculer une racine carrée. + +> 💡 **Analogie** : la bibliothèque standard, c'est une boîte à outils fournie avec ta maison. Un module, c'est un tiroir de cette boîte (le tiroir « dates », le tiroir « hasard »…). `import`, c'est ouvrir le tiroir pour y prendre ce dont tu as besoin. + +Tu en as déjà utilisé sans le savoir : `json` et `csv` au [chapitre 07](07-fichiers-csv-json.md), `pathlib` aussi. Ce chapitre t'explique enfin ce qui se passe vraiment derrière `import`. + +### 2. Importer un module + +#### `import module` + +La forme la plus simple et la plus courante : + +```python +import math + +print(math.sqrt(16)) +print(math.pi) +``` + +``` +4.0 +3.141592653589793 +``` + +`import math` charge le module et crée une variable `math` qui le représente. Pour utiliser son contenu, on écrit **`module.nom`** (notation pointée). L'avantage est énorme : en lisant `math.sqrt`, tu sais immédiatement d'où vient `sqrt`. + +#### `from module import nom` + +Si tu utilises souvent les mêmes fonctions, tu peux les importer directement : + +```python +from math import sqrt, pi + +print(sqrt(16)) +print(pi) +``` + +``` +4.0 +3.141592653589793 +``` + +Plus court à écrire, mais on perd l'information « ça vient de `math` ». Et attention : seuls les noms listés sont importés. `math` lui-même n'existe pas dans ce programme. + +#### Renommer avec `as` + +Tu peux donner un **alias** à un module ou à une fonction : + +```python +import statistics as stats +from datetime import datetime as dt + +print(stats.mean([12, 15, 9, 18])) +print(type(dt.now()).__name__) +``` + +``` +13.5 +datetime +``` + +Certains alias sont des conventions universelles que tu rencontreras au [chapitre 14](14-specialisations.md) : `import pandas as pd`, `import numpy as np`. + +#### `from module import *` : à éviter + +```python +from math import * +from statistics import * +``` + +L'étoile importe **tout** le contenu du module. Problème : tu ne sais plus d'où vient chaque nom, et si deux modules ont une fonction du même nom, la seconde écrase la première **en silence**. Réserve cette forme à l'interpréteur interactif pour tester rapidement. + +#### Quelle forme choisir ? + +| Forme | Quand l'utiliser | +|---|---| +| `import math` | Par défaut. Clair et sans conflit. | +| `from math import sqrt` | Pour 1 à 3 noms utilisés très souvent. | +| `import statistics as stats` | Pour un nom de module long, ou une convention connue. | +| `from math import *` | Jamais dans un vrai programme. | + +**Convention** (PEP 8, voir [chapitre 13](13-tests-pytest-bonnes-pratiques.md)) : les `import` se placent **tout en haut du fichier**, un module par ligne, dans cet ordre : bibliothèque standard, puis bibliothèques installées, puis tes propres modules, séparés par une ligne vide. + +```python +import json +import random +from pathlib import Path + +from mes_outils import nettoyer_texte +``` + +> ⚠️ Cet extrait suppose qu'un fichier `mes_outils.py` existe : il illustre seulement l'ordre des imports. + +### 3. Explorer un module : `dir()`, `help()` et la documentation + +Comment savoir ce que contient un module ? Trois outils : + +**`dir(module)`** liste tout ce qu'il contient : + +```python +import math + +print(dir(math)[:12]) +``` + +``` +['__doc__', '__loader__', '__name__', '__package__', '__spec__', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh'] +``` + +Les noms entourés de doubles tirets bas (`__doc__`…) sont des éléments techniques ; ignore-les pour l'instant. + +**`help(fonction)`** affiche la documentation (la *docstring*, vue au [chapitre 05](05-fonctions.md)). Essaie dans l'interpréteur interactif (`python` dans le terminal) : + +``` +>>> import math +>>> help(math.sqrt) +Help on built-in function sqrt in module math: + +sqrt(x, /) + Return the square root of x. +``` + +Appuie sur `q` pour quitter l'aide. + +**La documentation officielle**, en français : . Chaque module y a sa page, avec des exemples. Prends l'habitude d'y aller : c'est **la** référence. Dans VS Code, survoler une fonction ou faire **Ctrl+clic** dessus affiche aussi sa documentation. + +### 4. Créer ton propre module + +Créer un module, c'est… créer un fichier `.py`. Rien de plus. Prenons un dossier de projet avec deux fichiers **côte à côte** : + +``` +mon_projet/ +├── geometrie.py +└── main.py +``` + +`geometrie.py` : + +```python +"""Petites fonctions de géométrie.""" + +import math + +PI_ARRONDI = 3.14 + + +def aire_cercle(rayon): + """Renvoie l'aire d'un cercle.""" + return math.pi * rayon**2 + + +def perimetre_rectangle(longueur, largeur): + """Renvoie le périmètre d'un rectangle.""" + return 2 * (longueur + largeur) +``` + +`main.py` : + +```python +import geometrie + +print(f"{geometrie.aire_cercle(2):.2f}") +print(geometrie.perimetre_rectangle(3, 5)) +print(geometrie.PI_ARRONDI) +``` + +En lançant `python main.py` depuis le dossier `mon_projet` : + +``` +12.57 +16 +3.14 +``` + +Points importants : + +- On écrit `import geometrie` **sans** l'extension `.py`. +- Le nom du fichier doit être un **nom de variable valide** : lettres minuscules, chiffres, tirets bas. `mon-module.py` ou `2outils.py` ne pourront pas être importés (`import mon-module` provoque une `SyntaxError`). +- Le module a sa propre « boîte » de noms : la variable `PI_ARRONDI` de `geometrie` ne se mélange pas avec celles de `main.py`. On parle d'**espace de noms**. +- Un module peut lui-même importer d'autres modules (`geometrie` importe `math`). + +#### Où Python cherche-t-il les modules ? + +Quand tu écris `import geometrie`, Python cherche dans l'ordre : + +1. **le dossier du script lancé** (ici `mon_projet/`) ; +2. les dossiers de la bibliothèque standard ; +3. les dossiers des bibliothèques installées avec `pip` (voir [chapitre 12](12-outils-venv-pip-git.md)). + +Il prend **le premier fichier trouvé**. Tu peux voir cette liste de dossiers avec `sys.path` : + +```python +import sys + +for dossier in sys.path: + print(dossier) +``` + +Le premier dossier de la liste est celui de ton script. Retiens bien ce point, il explique le piège le plus célèbre de ce chapitre (fichier nommé `random.py`, voir les pièges). + +#### Un module n'est exécuté qu'une fois + +Lors du premier `import`, Python **exécute tout le fichier** du module, de haut en bas, puis le garde en mémoire. Les `import` suivants du même module (dans le même programme) réutilisent la version déjà chargée, sans le réexécuter. + +### 5. `if __name__ == "__main__":` + +Puisque `import` exécute tout le fichier, que se passe-t-il si ton module contient du code de test ? + +`temperatures.py` : + +```python +def celsius_vers_fahrenheit(celsius): + return celsius * 9 / 5 + 32 + + +print("Test :", celsius_vers_fahrenheit(100)) +``` + +`meteo.py` : + +```python +import temperatures + +print(f"Il fait {temperatures.celsius_vers_fahrenheit(20)} °F") +``` + +``` +Test : 212.0 +Il fait 68.0 °F +``` + +La ligne `Test : 212.0` s'affiche alors qu'on ne l'a pas demandée : elle vient de l'exécution de `temperatures.py` au moment de l'import. Gênant. + +La solution est une variable spéciale, **`__name__`**, que Python crée automatiquement dans chaque fichier : + +- si le fichier est **lancé directement** (`python temperatures.py`), `__name__` vaut `"__main__"` ; +- si le fichier est **importé**, `__name__` vaut le nom du module (`"temperatures"`). + +D'où ce schéma, que tu verras dans presque tous les fichiers Python : + +```python +def celsius_vers_fahrenheit(celsius): + return celsius * 9 / 5 + 32 + + +if __name__ == "__main__": + # Ce bloc ne s'exécute QUE si on lance ce fichier directement + print("Test :", celsius_vers_fahrenheit(100)) +``` + +Maintenant : + +- `python temperatures.py` affiche `Test : 212.0` ; +- `python meteo.py` affiche seulement `Il fait 68.0 °F`. + +> 💡 **Analogie** : un module, c'est comme un chef cuisinier. Quand on l'invite dans une autre cuisine (import), il apporte ses recettes mais ne se met pas à cuisiner tout seul. Quand il est dans sa propre cuisine (lancé directement), il peut faire une démonstration. + +Une organisation très répandue consiste à regrouper le programme principal dans une fonction `main()` : + +```python +def saluer(nom): + return f"Bonjour {nom} !" + + +def main(): + nom = input("Ton prénom : ") + print(saluer(nom)) + + +if __name__ == "__main__": + main() +``` + +Avec l'entrée `Léa` : + +``` +Ton prénom : Léa +Bonjour Léa ! +``` + +Ainsi, un autre fichier peut faire `from salutations import saluer` sans déclencher le `input()`. + +### 6. Les paquets : des modules rangés dans des dossiers + +Quand tu as beaucoup de modules, tu peux les regrouper dans un **dossier** : c'est un **paquet** (*package*). On y ajoute traditionnellement un fichier (souvent vide) nommé `__init__.py`, qui indique « ce dossier est un paquet Python ». + +``` +jeu/ +├── main.py +└── outils/ + ├── __init__.py + ├── des.py + └── affichage.py +``` + +`outils/des.py` : + +```python +import random + + +def lancer_de(faces=6): + return random.randint(1, faces) +``` + +`outils/affichage.py` : + +```python +def cadre(texte): + bordure = "+" + "-" * (len(texte) + 2) + "+" + return f"{bordure}\n| {texte} |\n{bordure}" +``` + +`main.py` : + +```python +from outils.des import lancer_de +from outils import affichage + +resultat = lancer_de() +print(affichage.cadre(f"Tu as fait {resultat}")) +``` + +Exemple de sortie (le nombre change à chaque exécution) : + +``` ++--------------+ +| Tu as fait 4 | ++--------------+ +``` + +On accède à un module d'un paquet avec un **point** : `outils.des`, comme un chemin de dossier. Tu peux imbriquer des paquets dans des paquets. La structure complète d'un projet est détaillée au [chapitre 13](13-tests-pytest-bonnes-pratiques.md). + +### 7. `random` : le hasard + +Le module `random` génère des nombres **pseudo-aléatoires** (calculés par un algorithme, mais imprévisibles en pratique). + +```python +import random + +print(random.randint(1, 6)) # entier entre 1 et 6, bornes INCLUSES +print(random.random()) # décimal dans [0, 1[ +print(random.uniform(1.5, 3.5)) # décimal entre 1.5 et 3.5 + +couleurs = ["rouge", "vert", "bleu", "jaune"] +print(random.choice(couleurs)) # un élément au hasard +print(random.choices(couleurs, k=5)) # 5 tirages AVEC remise (doublons possibles) +print(random.sample(range(1, 50), 6)) # 6 tirages SANS remise (loto) + +cartes = ["As", "Roi", "Dame", "Valet", "10"] +random.shuffle(cartes) # mélange la liste SUR PLACE (renvoie None) +print(cartes) +``` + +Exemple de sortie (différente à chaque exécution) : + +``` +6 +0.16931005030681068 +2.6574891618278063 +bleu +['jaune', 'bleu', 'jaune', 'jaune', 'jaune'] +[7, 34, 25, 47, 17, 40] +['Dame', 'As', '10', 'Valet', 'Roi'] +``` + +> ⚠️ Contrairement à `range(1, 6)` qui s'arrête à 5, **`randint(1, 6)` inclut le 6**. C'est une exception à la règle habituelle de Python. + +#### Tirage pondéré + +`choices` accepte des poids : ici « pile » a 9 fois plus de chances de sortir que « face ». + +```python +import random + +print(random.choices(["pile", "face"], weights=[9, 1], k=10)) +``` + +#### Rendre le hasard reproductible : `seed` + +Pour déboguer ou tester, on veut parfois **le même hasard** à chaque exécution. `random.seed(n)` fixe le point de départ de l'algorithme : + +```python +import random + +random.seed(2024) +print([random.randint(1, 100) for _ in range(5)]) +random.seed(2024) +print([random.randint(1, 100) for _ in range(5)]) +``` + +``` +[61, 24, 94, 75, 39] +[61, 24, 94, 75, 39] +``` + +Même graine, mêmes nombres. Très pratique pour écrire des tests. + +> ⚠️ **Sécurité** : `random` est prévisible si on connaît la graine. Pour des **mots de passe** ou des jetons secrets, utilise le module **`secrets`**, conçu pour la cryptographie : `secrets.choice(...)`, `secrets.randbelow(n)`, `secrets.token_hex(16)`. On s'en servira dans le mini-projet. + +### 8. `math` : les mathématiques + +```python +import math + +print(math.pi, math.e) +print(math.sqrt(2)) # racine carrée +print(math.floor(3.7), math.ceil(3.2)) # arrondi inférieur / supérieur +print(math.floor(-3.7), int(-3.7)) # attention aux négatifs ! +print(math.factorial(5)) # 5! = 120 +print(math.gcd(12, 18), math.lcm(4, 6)) # PGCD et PPCM +print(math.hypot(3, 4)) # hypoténuse : racine de 3² + 4² +print(math.dist((0, 0), (3, 4))) # distance entre deux points +print(math.log10(1000), math.log2(8)) # logarithmes +print(math.cos(math.pi)) # trigonométrie (en radians) +print(math.degrees(math.pi / 2), math.radians(180)) +``` + +``` +3.141592653589793 2.718281828459045 +1.4142135623730951 +3 4 +-4 -3 +120 +6 12 +5.0 +5.0 +3.0 3.0 +-1.0 +90.0 3.141592653589793 +``` + +`math.floor` arrondit **vers le bas** (vers moins l'infini) alors que `int()` **tronque** (supprime la partie décimale) : pour `-3.7`, on obtient `-4` et `-3`. + +#### Comparer des décimaux : `math.isclose` + +Tu te souviens de l'imprécision des `float` ([chapitre 01](01-variables-types-operateurs.md)) ? `math.isclose` permet de comparer deux décimaux « à peu près » : + +```python +import math + +print(0.1 + 0.2 == 0.3) +print(math.isclose(0.1 + 0.2, 0.3)) +``` + +``` +False +True +``` + +#### En bonus : `statistics` + +Pour les calculs statistiques simples, inutile de tout recoder : + +```python +import statistics + +notes = [12, 15, 9, 18] +print(statistics.mean(notes)) # moyenne +print(statistics.median(notes)) # médiane +print(round(statistics.stdev(notes), 2)) # écart-type +print(statistics.mode([1, 2, 2, 3])) # valeur la plus fréquente +``` + +``` +13.5 +13.5 +3.87 +2 +``` + +### 9. `datetime` : dates et heures + +Le module `datetime` contient plusieurs **types** (attention, l'un d'eux porte le même nom que le module !) : + +| Type | Représente | Exemple | +|---|---|---| +| `date` | une date (jour, mois, année) | 14 juillet 2025 | +| `datetime` | une date **et** une heure | 31/12/2025 à 23:59:30 | +| `timedelta` | une **durée** | 30 jours, 2 heures | + +On les importe généralement ainsi : + +```python +from datetime import date, datetime, timedelta +``` + +#### Créer et lire une date + +```python +from datetime import date, datetime + +fete = date(2025, 7, 14) # année, mois, jour +print(fete) +print(fete.year, fete.month, fete.day) +print(fete.weekday()) # 0 = lundi, 6 = dimanche + +reveillon = datetime(2025, 12, 31, 23, 59, 30) +print(reveillon) +print(reveillon.hour, reveillon.minute) + +print(type(date.today()).__name__) # la date du jour +print(type(datetime.now()).__name__) # la date et l'heure actuelles +``` + +``` +2025-07-14 +2025 7 14 +0 +2025-12-31 23:59:30 +23 59 +date +datetime +``` + +#### Calculer avec les dates : `timedelta` + +Additionner une durée à une date, ou soustraire deux dates, c'est là que `datetime` brille. Il gère pour toi les mois de 28, 30 ou 31 jours et les années bissextiles : + +```python +from datetime import date, datetime, timedelta + +fete = date(2025, 7, 14) +print(fete + timedelta(days=30)) # dans 30 jours +print(fete - timedelta(weeks=2)) # il y a 2 semaines + +ecart = date(2025, 12, 25) - fete # date - date = timedelta +print(ecart) +print(ecart.days) + +print(date(2024, 2, 28) + timedelta(days=1)) # 2024 est bissextile +print(date(2025, 2, 28) + timedelta(days=1)) + +duree = datetime(2026, 1, 1, 10, 0) - datetime(2025, 12, 31, 22, 30) +print(duree, "soit", duree.total_seconds(), "secondes") +``` + +``` +2025-08-13 +2025-06-30 +164 days, 0:00:00 +164 +2024-02-29 +2025-03-01 +11:30:00 soit 41400.0 secondes +``` + +#### Formater et lire du texte : `strftime` / `strptime` + +- **`strftime`** (*string format time*) : date → texte, selon un **format**. +- **`strptime`** (*string parse time*) : texte → date, selon un format. + +| Code | Signification | Exemple | +|---|---|---| +| `%d` | jour sur 2 chiffres | `03` | +| `%m` | mois sur 2 chiffres | `02` | +| `%Y` | année sur 4 chiffres | `2026` | +| `%H` | heure (00-23) | `14` | +| `%M` | minutes | `30` | +| `%S` | secondes | `05` | +| `%A` / `%B` | nom du jour / du mois (en anglais par défaut) | `Monday` / `July` | + +```python +from datetime import date, datetime + +fete = date(2025, 7, 14) +print(fete.strftime("%d/%m/%Y")) + +rdv = datetime.strptime("03/02/2026 14:30", "%d/%m/%Y %H:%M") +print(rdv) +print(rdv.strftime("Rendez-vous le %d/%m à %Hh%M")) + +print(date.fromisoformat("2026-02-03")) # format international AAAA-MM-JJ +``` + +``` +14/07/2025 +2026-02-03 14:30:00 +Rendez-vous le 03/02 à 14h30 +2026-02-03 +``` + +Les noms de jours et de mois (`%A`, `%B`) sortent en anglais par défaut. La façon la plus simple et la plus fiable d'avoir le français est d'utiliser ta propre liste : + +```python +from datetime import date + +JOURS = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"] +MOIS = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", + "août", "septembre", "octobre", "novembre", "décembre"] + +fete = date(2025, 7, 14) +print(f"{JOURS[fete.weekday()]} {fete.day} {MOIS[fete.month - 1]} {fete.year}") +``` + +``` +lundi 14 juillet 2025 +``` + +> 💡 Pour stocker une date dans un fichier JSON ou CSV, utilise le format ISO (`date.isoformat()` → `"2025-07-14"`) et relis-le avec `date.fromisoformat()`. Ce format se trie correctement par ordre alphabétique ! + +### 10. `os` et `sys` : parler au système + +#### `os` : le système d'exploitation + +```python +import os + +print(os.getcwd()) # dossier de travail actuel +print(os.listdir(".")) # contenu d'un dossier +print(os.environ.get("HOME")) # variable d'environnement (None si absente) +print(os.sep) # séparateur de chemin : / ou \ +``` + +La sortie dépend de ton ordinateur. Par exemple sous Linux : + +``` +/home/toi/cours +['mon_script.py', 'notes.txt'] +/home/toi +/ +``` + +Le module `os` contient aussi des fonctions pour créer ou supprimer des dossiers (`os.makedirs`, `os.remove`, `os.rename`…). Mais pour manipuler des **chemins et fichiers**, préfère **`pathlib`** (vu au [chapitre 07](07-fichiers-csv-json.md)), plus moderne et plus lisible : + +| Avec `os` | Avec `pathlib` | +|---|---| +| `os.getcwd()` | `Path.cwd()` | +| `os.listdir(d)` | `Path(d).iterdir()` | +| `os.path.exists(c)` | `Path(c).exists()` | +| `os.path.join(d, "a.txt")` | `Path(d) / "a.txt"` | +| `os.makedirs(d, exist_ok=True)` | `Path(d).mkdir(parents=True, exist_ok=True)` | + +`os` reste utile pour les **variables d'environnement** (`os.environ`), très utilisées pour stocker des réglages ou des mots de passe en dehors du code. + +#### `sys` : l'interpréteur Python + +```python +import sys + +print(sys.version_info >= (3, 12)) # vérifier la version de Python +print(sys.argv) # arguments de la ligne de commande +``` + +**`sys.argv`** est une liste contenant le nom du script suivi des **arguments** tapés dans le terminal. Fichier `bonjour.py` : + +```python +import sys + +if len(sys.argv) < 2: + print("Usage : python bonjour.py PRENOM") + sys.exit(1) # arrête le programme avec un code d'erreur + +prenom = sys.argv[1] +print(f"Bonjour {prenom} !") +``` + +``` +$ python bonjour.py +Usage : python bonjour.py PRENOM +$ python bonjour.py Léa +Bonjour Léa ! +``` + +`sys.exit()` arrête immédiatement le programme. Par convention, le code `0` signifie « tout va bien » et tout autre nombre signale une erreur. C'est la base des scripts d'automatisation du [chapitre 14](14-specialisations.md). + +### 11. `collections` : des structures de données en plus + +Le module `collections` offre des versions « améliorées » des listes et dictionnaires du [chapitre 04](04-structures-de-donnees.md). + +#### `Counter` : compter + +Compter des éléments est si fréquent qu'il existe un outil dédié : + +```python +from collections import Counter + +lettres = Counter("abracadabra") +print(lettres) +print(lettres["a"], lettres["z"]) # pas de KeyError : 0 si absent +print(lettres.most_common(2)) # les 2 plus fréquents + +mots = "le chat et le chien et le lapin".split() +print(Counter(mots).most_common(3)) +``` + +``` +Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1}) +5 0 +[('a', 5), ('b', 2)] +[('le', 3), ('et', 2), ('chat', 1)] +``` + +Compare avec la version « à la main » du chapitre 04 : une boucle, un `if mot in compteur`, une incrémentation… `Counter` fait tout en une ligne. + +#### `defaultdict` : un dictionnaire avec valeur par défaut + +Pour regrouper des éléments, on écrit souvent : « si la clé n'existe pas, crée une liste vide, puis ajoute ». `defaultdict` le fait automatiquement : + +```python +from collections import defaultdict + +eleves = [("Alice", "A"), ("Bob", "B"), ("Chloé", "A"), ("David", "C")] + +par_classe = defaultdict(list) # valeur par défaut : une liste vide +for nom, classe in eleves: + par_classe[classe].append(nom) # pas besoin de vérifier si la clé existe + +print(dict(par_classe)) +``` + +``` +{'A': ['Alice', 'Chloé'], 'B': ['Bob'], 'C': ['David']} +``` + +On passe à `defaultdict` **le type** de la valeur par défaut, sans parenthèses : `list` (liste vide), `int` (0), `set` (ensemble vide), `str` (chaîne vide). + +#### `namedtuple` : des tuples avec des noms + +```python +from collections import namedtuple + +Point = namedtuple("Point", ["x", "y"]) +p = Point(3, 4) +print(p) +print(p.x, p.y) # accès par nom : plus lisible que p[0] +print(p[0]) # l'accès par indice marche toujours +``` + +``` +Point(x=3, y=4) +3 4 +3 +``` + +C'est un premier pas vers les **classes** du [chapitre 10](10-poo-classes-objets.md) et les *dataclasses* du [chapitre 11](11-poo-avancee-heritage-typage.md), qui feront la même chose en plus puissant. + +#### `deque` : une file à double entrée + +`deque` (prononcé « dèque ») est une liste optimisée pour ajouter et retirer **aux deux bouts**. Avec `maxlen`, elle ne garde que les N derniers éléments, idéal pour un historique : + +```python +from collections import deque + +derniers = deque(maxlen=3) +for numero in range(1, 6): + derniers.append(numero) + print(list(derniers)) +``` + +``` +[1] +[1, 2] +[1, 2, 3] +[2, 3, 4] +[3, 4, 5] +``` + +### 12. D'autres modules utiles à connaître + +Tu n'as pas besoin de tout apprendre par cœur. Retiens surtout qu'**ils existent**, pour penser à les chercher le jour venu : + +| Module | Pour quoi faire | Exemple | +|---|---|---| +| `time` | pauses, mesurer une durée | `time.sleep(1)`, `time.perf_counter()` | +| `string` | constantes de caractères | `string.ascii_letters`, `string.digits` | +| `secrets` | hasard sécurisé | `secrets.choice(...)`, `secrets.token_hex(8)` | +| `statistics` | moyenne, médiane… | `statistics.median(...)` | +| `json`, `csv` | formats de fichiers | vus au [chapitre 07](07-fichiers-csv-json.md) | +| `pathlib`, `shutil` | fichiers, copies de dossiers | `shutil.copy(...)` | +| `itertools` | combinaisons, permutations | `itertools.permutations("abc")` | +| `textwrap` | mise en forme de texte | `textwrap.wrap(texte, 40)` | +| `urllib.request` | télécharger une page web | (on préférera `requests`, chapitre 14) | + +```python +import string +import time + +print(string.ascii_lowercase) +print(string.digits) + +debut = time.perf_counter() +total = sum(range(1_000_000)) +duree = time.perf_counter() - debut +print(f"Calcul effectué en {duree:.4f} s") +``` + +Exemple de sortie (la durée varie selon l'ordinateur) : + +``` +abcdefghijklmnopqrstuvwxyz +0123456789 +Calcul effectué en 0.0123 s +``` + +Et quand la bibliothèque standard ne suffit pas, il existe plus de 500 000 bibliothèques supplémentaires sur PyPI, qu'on installe avec `pip` : ce sera le sujet du [chapitre 12](12-outils-venv-pip-git.md). + +--- + +## Pièges fréquents + +### Piège 1 — Nommer son fichier comme un module de la bibliothèque standard + +C'est **le** piège classique. Tu veux tester le module `random`, alors tu crées un fichier… `random.py` : + +```python +# fichier : random.py +import random + +print(random.randint(1, 6)) +``` + +``` +Traceback (most recent call last): + File "/home/toi/cours/random.py", line 1, in + import random + File "/home/toi/cours/random.py", line 3, in + print(random.randint(1, 6)) + ^^^^^^^^^^^^^^ +AttributeError: module 'random' has no attribute 'randint' (consider renaming '/home/toi/cours/random.py' since it has the same name as the standard library module named 'random' and prevents importing that standard library module) +``` + +Pourquoi ? Python cherche d'abord dans **le dossier du script** (section 4) : `import random` importe donc… ton propre fichier, qui n'a pas de fonction `randint`. Python 3.13 te donne même la solution dans le message (*consider renaming*). En Python 3.12, le message est moins clair : `AttributeError: partially initialized module 'random' has no attribute 'randint' (most likely due to a circular import)`. + +✅ **Correction** : renomme ton fichier (`test_hasard.py`, `mon_random.py`…) **et supprime le dossier `__pycache__`** s'il a été créé à côté. Noms à ne jamais donner à tes fichiers : `random.py`, `math.py`, `json.py`, `csv.py`, `string.py`, `time.py`, `datetime.py`, `email.py`, `test.py`, `turtle.py`… + +### Piège 2 — L'import circulaire + +Deux modules qui s'importent **mutuellement** : + +```python +# fichier : joueurs.py +from scores import meilleur_score + + +def creer_joueur(nom): + return {"nom": nom, "score": 0} +``` + +```python +# fichier : scores.py +from joueurs import creer_joueur + + +def meilleur_score(joueurs): + return max(joueur["score"] for joueur in joueurs) +``` + +En lançant un `main.py` qui contient `from joueurs import creer_joueur`, en Python 3.12 : + +``` +Traceback (most recent call last): + File "/home/toi/cours/main.py", line 1, in + from joueurs import creer_joueur + File "/home/toi/cours/joueurs.py", line 1, in + from scores import meilleur_score + File "/home/toi/cours/scores.py", line 1, in + from joueurs import creer_joueur +ImportError: cannot import name 'creer_joueur' from partially initialized module 'joueurs' (most likely due to a circular import) (/home/toi/cours/joueurs.py) +``` + +(En Python 3.13, la dernière ligne est formulée autrement : `ImportError: cannot import name 'creer_joueur' from 'joueurs' (consider renaming ...)`. Ne te laisse pas tromper : il ne s'agit pas d'un conflit de nom ici, mais bien d'un import circulaire. Le signe qui ne trompe pas : dans le traceback, le même fichier `joueurs.py` apparaît au début **et** est réimporté à la fin.) + +Python commence à exécuter `joueurs.py`, qui demande `scores.py`, qui redemande `joueurs.py`… lequel n'est pas encore fini de charger (*partially initialized*) : `creer_joueur` n'existe pas encore. + +✅ **Corrections**, de la meilleure à la moins bonne : + +1. **Réorganiser** : souvent, l'un des deux modules n'a pas vraiment besoin de l'autre. Ici `joueurs.py` n'utilise même pas `meilleur_score` : on supprime l'import. +2. Déplacer le code commun dans un **troisième module** que les deux importent. +3. En dernier recours, déplacer l'`import` **à l'intérieur** de la fonction qui en a besoin. + +### Piège 3 — Confondre le module `datetime` et le type `datetime` + +```python +import datetime + +maintenant = datetime.now() +``` + +``` +AttributeError: module 'datetime' has no attribute 'now' +``` + +Ici, `datetime` désigne le **module** ; le type est à l'intérieur : `datetime.datetime.now()`. ✅ Plus lisible : + +```python +from datetime import datetime + +maintenant = datetime.now() +``` + +### Piège 4 — Oublier le préfixe du module (ou l'import) + +```python +import math + +print(sqrt(16)) +``` + +``` +NameError: name 'sqrt' is not defined +``` + +Avec `import math`, il faut écrire `math.sqrt(16)`. ✅ Ou bien `from math import sqrt`. + +### Piège 5 — Mettre l'extension ou un tiret dans l'import + +```python +import outils.py +``` + +``` +ModuleNotFoundError: No module named 'outils.py'; 'outils' is not a package +``` + +Python croit que `outils` est un paquet contenant un module `py` ! ✅ `import outils`. De même, un fichier `mon-module.py` ne peut pas être importé (le tiret est lu comme une soustraction) : nomme-le `mon_module.py`. + +### Piège 6 — `ModuleNotFoundError` parce qu'on n'est pas dans le bon dossier + +``` +ModuleNotFoundError: No module named 'geometrie' +``` + +Le fichier existe pourtant bien ! Causes fréquentes : + +- `geometrie.py` n'est **pas dans le même dossier** que le script lancé ; +- faute de frappe dans le nom (`geometrie` / `geometry`) ; +- pour une bibliothèque externe : elle n'est pas installée dans l'environnement utilisé (voir [chapitre 12](12-outils-venv-pip-git.md)). + +✅ Vérifie avec `print(sys.path)` où Python cherche, et place tes modules à côté de ton script principal. + +### Piège 7 — `random.shuffle` renvoie `None` + +```python +import random + +cartes = ["As", "Roi", "Dame"] +cartes = random.shuffle(cartes) +print(cartes) +``` + +``` +None +``` + +`shuffle` mélange la liste **sur place** et ne renvoie rien (comme `liste.sort()`). En réaffectant, on a écrasé la liste par `None`. ✅ Écris juste `random.shuffle(cartes)`, ou utilise `random.sample(cartes, len(cartes))` pour obtenir une **nouvelle** liste mélangée. + +### Piège 8 — Utiliser `random` pour des mots de passe + +`random` est conçu pour les simulations et les jeux, pas pour la sécurité : ses tirages sont reproductibles par quelqu'un qui connaît son état interne. ✅ Pour tout ce qui est secret (mots de passe, jetons, codes de validation), utilise `secrets`. + +--- + +## Exercices + +### Exercice 1 ⭐ — Lancer de dés + +Écris un programme qui simule le lancer de **deux dés à six faces**, affiche chaque dé et leur somme, et affiche `Double !` si les deux dés sont identiques. + +``` +Dé 1 : 4 +Dé 2 : 4 +Somme : 8 +Double ! +``` + +### Exercice 2 ⭐ — Calculs géométriques + +En utilisant le module `math`, écris trois fonctions : + +- `aire_disque(rayon)` ; +- `hypotenuse(a, b)` : la longueur de l'hypoténuse d'un triangle rectangle ; +- `nombre_de_dalles(surface_m2, surface_dalle_m2)` : le nombre de dalles à acheter (on ne peut pas acheter une fraction de dalle, donc on arrondit **au-dessus**). + +```python +print(f"{aire_disque(3):.2f}") # 28.27 +print(hypotenuse(6, 8)) # 10.0 +print(nombre_de_dalles(10.5, 0.36)) # 30 +``` + +### Exercice 3 ⭐ — Combien de jours ? + +Écris une fonction `jours_avant(jour_cible, aujourd_hui)` qui reçoit deux objets `date` et renvoie le nombre de jours qui les séparent. Puis écris une fonction `prochain_anniversaire(naissance, aujourd_hui)` qui renvoie le nombre de jours avant le prochain anniversaire (0 si c'est aujourd'hui). + +```python +aujourd_hui = date(2026, 3, 10) +print(jours_avant(date(2026, 12, 25), aujourd_hui)) # 290 +print(prochain_anniversaire(date(1998, 5, 1), aujourd_hui)) # 52 +print(prochain_anniversaire(date(2001, 1, 15), aujourd_hui)) # 311 +print(prochain_anniversaire(date(1990, 3, 10), aujourd_hui)) # 0 +``` + +(On ignore le cas des personnes nées un 29 février.) + +### Exercice 4 ⭐⭐ — Analyse d'un texte avec `Counter` + +Écris une fonction `analyser(texte)` qui affiche : + +- le nombre total de mots ; +- le nombre de mots différents ; +- les 3 mots les plus fréquents (en minuscules, ponctuation `.,;:!?` retirée) ; +- la lettre la plus fréquente (on ne compte que les lettres). + +```python +analyser("Le petit chat boit du lait. Le chat dort. Le chien aboie !") +``` + +``` +Mots : 12 +Mots différents : 9 +Top 3 : le (3), chat (2), petit (1) +Lettre la plus fréquente : t +``` + +### Exercice 5 ⭐⭐ — Ton premier module + +Crée un module `conversions.py` contenant : + +- `km_vers_miles(km)` (1 mile = 1,609344 km) ; +- `celsius_vers_fahrenheit(c)` ; +- `secondes_vers_hms(secondes)` qui renvoie une chaîne `"HH:MM:SS"`. + +Le module doit contenir un bloc `if __name__ == "__main__":` qui vérifie les trois fonctions avec des `assert` et affiche `Tous les tests sont passés.` + +Crée ensuite un fichier `principal.py` qui importe le module et affiche : + +``` +42.195 km = 26.22 miles +37 °C = 98.6 °F +5000 s = 01:23:20 +``` + +Vérifie que lancer `principal.py` n'affiche **pas** le message des tests. + +### Exercice 6 ⭐⭐ — Répertoire par initiale + +Avec `defaultdict`, écris une fonction `regrouper_par_initiale(noms)` qui renvoie un dictionnaire associant chaque initiale (en majuscule) à la liste **triée** des noms qui commencent par cette lettre. Les clés doivent elles aussi être triées. + +```python +noms = ["léa", "Hugo", "lucas", "Emma", "hugo", "Inès", "Ethan", "Louise"] +print(regrouper_par_initiale(noms)) +``` + +``` +{'E': ['Emma', 'Ethan'], 'H': ['Hugo', 'Hugo'], 'I': ['Inès'], 'L': ['Louise', 'Lucas', 'Léa']} +``` + +(Les noms sont remis en forme avec `.capitalize()`.) + +### Exercice 7 ⭐⭐⭐ — Simulation statistique + +Simule **10 000 lancers** de deux dés et compte, avec `Counter`, combien de fois chaque somme (2 à 12) apparaît. Affiche un histogramme horizontal, où chaque `#` représente 50 lancers, avec le pourcentage. Utilise `random.seed(42)` pour obtenir des résultats reproductibles. + +``` + 2 : 2.7 % ##### + 3 : 5.4 % ########## +... + 7 : 17.0 % ################################## +... +``` + +Question bonus : pourquoi le 7 est-il la somme la plus fréquente ? + +### Exercice 8 ⭐⭐⭐ — Un outil en ligne de commande + +Écris un script `compter.py` qui s'utilise ainsi dans le terminal : + +``` +$ python compter.py +Usage : python compter.py FICHIER [NOMBRE] +$ python compter.py absent.txt +Erreur : le fichier 'absent.txt' n'existe pas. +$ python compter.py poeme.txt 3 +poeme.txt : 6 lignes, 26 mots +Les 3 mots les plus fréquents : + la : 4 + mer : 3 + le : 2 +``` + +- Le premier argument est le chemin d'un fichier texte, le second (optionnel, 5 par défaut) le nombre de mots à afficher. +- Utilise `sys.argv`, `sys.exit`, `pathlib` et `Counter`. +- Si le second argument n'est pas un entier, affiche une erreur claire. +- Organise ton code avec une fonction `main()` et `if __name__ == "__main__":`. + +Fichier `poeme.txt` de test : + +``` +La mer, la mer, toujours recommencée. +Le vent se lève, il faut tenter de vivre. +La mer brille et le ciel +s'endort et +la vague +chante. +``` + +--- + +## Mini-projet — Générateur de mots de passe et module utilitaire maison + +### Cahier des charges + +Tu vas construire un petit projet sur **deux fichiers** : + +``` +generateur_mdp/ +├── mdp_outils.py ← ton module utilitaire réutilisable +└── generateur.py ← le programme principal (menu) +``` + +**Le module `mdp_outils.py`** doit fournir : + +1. `generer_mot_de_passe(longueur=12, majuscules=True, chiffres=True, symboles=True)` : renvoie un mot de passe aléatoire **sécurisé** (module `secrets`), contenant **au moins un caractère de chaque catégorie demandée** (les minuscules sont toujours incluses). Lève une `ValueError` si la longueur est inférieure à 4 ou au nombre de catégories. +2. `generer_phrase_de_passe(nombre_mots=4, separateur="-")` : assemble des mots tirés d'une liste (au moins 20 mots) et ajoute un nombre à 2 chiffres à la fin. Exemple : `tigre-nuage-velo-orange-42`. +3. `evaluer_force(mot_de_passe)` : renvoie un tuple `(score, verdict)` où le score (0 à 5) compte un point pour : longueur ≥ 8, longueur ≥ 12, présence de majuscules, de chiffres, de symboles. Verdict : `"faible"` (0-2), `"moyen"` (3), `"fort"` (4-5). +4. Un bloc `if __name__ == "__main__":` avec des tests (`assert`). + +**Le programme `generateur.py`** affiche un menu en boucle : + +``` +=== Générateur de mots de passe === +1. Mot de passe aléatoire +2. Phrase de passe +3. Tester la force d'un mot de passe +4. Voir l'historique de la session +5. Quitter +Ton choix : +``` + +- Le choix 1 demande la longueur et les options (o/n) ; les saisies invalides sont gérées (chapitre 08 !). +- Les choix 1 et 2 affichent le résultat avec sa force, et l'ajoutent à l'historique avec l'heure (`datetime`). +- L'historique ne garde que les **5 dernières** générations (`deque`). + +### Étapes suggérées + +1. Commence par `mdp_outils.py` et sa fonction `evaluer_force` (la plus simple), avec des `assert` dans le bloc `__main__`. Lance le module seul pour tester. +2. Écris `generer_mot_de_passe` : construis l'alphabet autorisé avec `string.ascii_lowercase`, `string.ascii_uppercase`, `string.digits`, `string.punctuation`. Tire d'abord **un caractère obligatoire** par catégorie, complète avec des caractères tirés dans l'alphabet complet, puis **mélange** le tout (`secrets.SystemRandom().shuffle`). +3. Écris `generer_phrase_de_passe`. +4. Écris `generateur.py` : une fonction par choix du menu, une fonction `demander_entier(message, minimum, maximum)` et une fonction `demander_oui_non(message)`. +5. Ajoute l'historique avec `deque(maxlen=5)` et `datetime.now().strftime("%H:%M:%S")`. + +### Pistes d'amélioration + +- Option « éviter les caractères ambigus » (`l`, `1`, `I`, `O`, `0`). +- Copier automatiquement le mot de passe dans le presse-papiers (bibliothèque externe `pyperclip`, voir [chapitre 12](12-outils-venv-pip-git.md)). +- Lire la liste de mots de la phrase de passe dans un fichier texte. +- Accepter des arguments en ligne de commande (`python generateur.py --longueur 20`) avec le module `argparse`. + +--- + +## Corrigés + +
Corrigé de l'exercice 1 + +```python +import random + +de1 = random.randint(1, 6) +de2 = random.randint(1, 6) + +print(f"Dé 1 : {de1}") +print(f"Dé 2 : {de2}") +print(f"Somme : {de1 + de2}") +if de1 == de2: + print("Double !") +``` + +Exemple de sortie (différente à chaque exécution) : + +``` +Dé 1 : 3 +Dé 2 : 5 +Somme : 8 +``` + +`randint(1, 6)` inclut bien le 1 **et** le 6. Pour tester le cas « Double ! », tu peux temporairement ajouter `random.seed(...)` en essayant plusieurs graines, ou forcer `de2 = de1`. + +
+ +
Corrigé de l'exercice 2 + +```python +import math + + +def aire_disque(rayon): + return math.pi * rayon**2 + + +def hypotenuse(a, b): + return math.hypot(a, b) # ou math.sqrt(a**2 + b**2) + + +def nombre_de_dalles(surface_m2, surface_dalle_m2): + return math.ceil(surface_m2 / surface_dalle_m2) + + +print(f"{aire_disque(3):.2f}") +print(hypotenuse(6, 8)) +print(nombre_de_dalles(10.5, 0.36)) +``` + +``` +28.27 +10.0 +30 +``` + +10,5 / 0,36 = 29,17 : avec 29 dalles, il manquerait un morceau, d'où `math.ceil` (arrondi supérieur). `round` aurait donné 29, et `int` aussi. + +
+ +
Corrigé de l'exercice 3 + +```python +from datetime import date + + +def jours_avant(jour_cible, aujourd_hui): + return (jour_cible - aujourd_hui).days + + +def prochain_anniversaire(naissance, aujourd_hui): + anniversaire = date(aujourd_hui.year, naissance.month, naissance.day) + if anniversaire < aujourd_hui: # déjà passé cette année + anniversaire = date(aujourd_hui.year + 1, naissance.month, naissance.day) + return jours_avant(anniversaire, aujourd_hui) + + +aujourd_hui = date(2026, 3, 10) +print(jours_avant(date(2026, 12, 25), aujourd_hui)) +print(prochain_anniversaire(date(1998, 5, 1), aujourd_hui)) +print(prochain_anniversaire(date(2001, 1, 15), aujourd_hui)) +print(prochain_anniversaire(date(1990, 3, 10), aujourd_hui)) +``` + +``` +290 +52 +311 +0 +``` + +Pourquoi passer `aujourd_hui` en paramètre plutôt que d'appeler `date.today()` dans la fonction ? Pour pouvoir **tester** la fonction avec une date fixe : le résultat est alors toujours le même. Dans le programme réel, on appellera `prochain_anniversaire(naissance, date.today())`. Les dates se comparent directement avec `<`. + +
+ +
Corrigé de l'exercice 4 + +```python +from collections import Counter + + +def analyser(texte): + mots = [mot.strip(".,;:!?").lower() for mot in texte.split()] + mots = [mot for mot in mots if mot] # retire les « mots » vides (ex : « ! » seul) + compteur_mots = Counter(mots) + + lettres = Counter(c for c in texte.lower() if c.isalpha()) + lettre, _ = lettres.most_common(1)[0] + + top = ", ".join(f"{mot} ({n})" for mot, n in compteur_mots.most_common(3)) + print(f"Mots : {len(mots)}") + print(f"Mots différents : {len(compteur_mots)}") + print(f"Top 3 : {top}") + print(f"Lettre la plus fréquente : {lettre}") + + +analyser("Le petit chat boit du lait. Le chat dort. Le chien aboie !") +``` + +``` +Mots : 12 +Mots différents : 9 +Top 3 : le (3), chat (2), petit (1) +Lettre la plus fréquente : t +``` + +Le `!` isolé devient une chaîne vide après `strip` : sans le filtre, on compterait 13 « mots ». `len(compteur_mots)` donne le nombre de clés, donc de mots **différents**. `most_common(1)` renvoie une liste contenant un tuple : `[('t', 6)]`, d'où le `[0]` puis le dépaquetage. À égalité, `most_common` garde l'ordre de première apparition (c'est pourquoi `petit` sort avant les autres mots vus une seule fois). + +
+ +
Corrigé de l'exercice 5 + +Fichier `conversions.py` : + +```python +"""Fonctions de conversion d'unités.""" + +KM_PAR_MILE = 1.609344 + + +def km_vers_miles(km): + return km / KM_PAR_MILE + + +def celsius_vers_fahrenheit(c): + return c * 9 / 5 + 32 + + +def secondes_vers_hms(secondes): + heures, reste = divmod(secondes, 3600) + minutes, secondes = divmod(reste, 60) + return f"{heures:02d}:{minutes:02d}:{secondes:02d}" + + +if __name__ == "__main__": + assert round(km_vers_miles(1.609344), 6) == 1 + assert celsius_vers_fahrenheit(100) == 212 + assert celsius_vers_fahrenheit(-40) == -40 + assert secondes_vers_hms(3661) == "01:01:01" + assert secondes_vers_hms(0) == "00:00:00" + print("Tous les tests sont passés.") +``` + +Fichier `principal.py`, dans le **même dossier** : + +```python +import conversions + +print(f"42.195 km = {conversions.km_vers_miles(42.195):.2f} miles") +print(f"37 °C = {conversions.celsius_vers_fahrenheit(37):.1f} °F") +print(f"5000 s = {conversions.secondes_vers_hms(5000)}") +``` + +`python principal.py` : + +``` +42.195 km = 26.22 miles +37 °C = 98.6 °F +5000 s = 01:23:20 +``` + +`python conversions.py` : + +``` +Tous les tests sont passés. +``` + +`divmod(a, b)` renvoie à la fois le quotient et le reste (`a // b`, `a % b`). Le format `:02d` affiche un entier sur 2 chiffres en complétant par des zéros. La constante `KM_PAR_MILE` est en majuscules : c'est la convention pour une valeur qui ne change jamais. + +
+ +
Corrigé de l'exercice 6 + +```python +from collections import defaultdict + + +def regrouper_par_initiale(noms): + groupes = defaultdict(list) + for nom in noms: + nom = nom.capitalize() + groupes[nom[0]].append(nom) + return {initiale: sorted(groupes[initiale]) for initiale in sorted(groupes)} + + +noms = ["léa", "Hugo", "lucas", "Emma", "hugo", "Inès", "Ethan", "Louise"] +print(regrouper_par_initiale(noms)) +``` + +``` +{'E': ['Emma', 'Ethan'], 'H': ['Hugo', 'Hugo'], 'I': ['Inès'], 'L': ['Louise', 'Lucas', 'Léa']} +``` + +La compréhension de dictionnaire finale ([chapitre 06](06-chaines-et-comprehensions.md)) trie à la fois les clés et chaque liste, et transforme le `defaultdict` en `dict` ordinaire. Remarque que `Léa` arrive **après** `Lucas` : le tri compare les codes des caractères, et `é` a un code plus grand que toutes les lettres non accentuées. Trier « à la française » demanderait le module `locale` ou une clé de tri qui retire les accents. + +
+ +
Corrigé de l'exercice 7 + +```python +import random +from collections import Counter + +NOMBRE_LANCERS = 10_000 +LANCERS_PAR_DIESE = 50 + +random.seed(42) +sommes = Counter( + random.randint(1, 6) + random.randint(1, 6) for _ in range(NOMBRE_LANCERS) +) + +for somme in range(2, 13): + nombre = sommes[somme] + pourcentage = nombre / NOMBRE_LANCERS * 100 + barre = "#" * (nombre // LANCERS_PAR_DIESE) + print(f"{somme:2} : {pourcentage:5.1f} % {barre}") +``` + +``` + 2 : 2.7 % ##### + 3 : 5.4 % ########## + 4 : 8.2 % ################ + 5 : 10.9 % ##################### + 6 : 13.6 % ########################### + 7 : 17.0 % ################################## + 8 : 13.9 % ########################### + 9 : 11.2 % ###################### +10 : 8.4 % ################ +11 : 5.6 % ########### +12 : 3.1 % ###### +``` + +On parcourt `range(2, 13)` plutôt que le `Counter` directement, pour avoir les sommes **dans l'ordre**. `{somme:2}` aligne à droite sur 2 caractères, `{pourcentage:5.1f}` sur 5 caractères avec 1 décimale. + +**Bonus** : il y a 36 combinaisons possibles de deux dés. Le 7 s'obtient de 6 façons (1+6, 2+5, 3+4, 4+3, 5+2, 6+1), soit 6/36 ≈ 16,7 %, alors que le 2 ne s'obtient que d'une façon (1+1), soit 1/36 ≈ 2,8 %. La simulation retrouve bien ces valeurs théoriques. + +
+ +
Corrigé de l'exercice 8 + +```python +"""Compte les lignes et les mots d'un fichier texte.""" + +import sys +from collections import Counter +from pathlib import Path + +PONCTUATION = ".,;:!?\"'()" + + +def compter_mots(texte): + mots = (mot.strip(PONCTUATION).lower() for mot in texte.split()) + return Counter(mot for mot in mots if mot) + + +def main(): + if len(sys.argv) < 2: + print("Usage : python compter.py FICHIER [NOMBRE]") + sys.exit(1) + + chemin = Path(sys.argv[1]) + nombre = 5 + if len(sys.argv) >= 3: + try: + nombre = int(sys.argv[2]) + except ValueError: + print(f"Erreur : {sys.argv[2]!r} n'est pas un nombre entier.") + sys.exit(1) + + try: + texte = chemin.read_text(encoding="utf-8") + except FileNotFoundError: + print(f"Erreur : le fichier '{chemin}' n'existe pas.") + sys.exit(1) + + compteur = compter_mots(texte) + nb_lignes = len(texte.splitlines()) + nb_mots = compteur.total() + print(f"{chemin.name} : {nb_lignes} lignes, {nb_mots} mots") + print(f"Les {nombre} mots les plus fréquents :") + for mot, occurrences in compteur.most_common(nombre): + print(f" {mot} : {occurrences}") + + +if __name__ == "__main__": + main() +``` + +``` +$ python compter.py +Usage : python compter.py FICHIER [NOMBRE] +$ python compter.py absent.txt +Erreur : le fichier 'absent.txt' n'existe pas. +$ python compter.py poeme.txt trois +Erreur : 'trois' n'est pas un nombre entier. +$ python compter.py poeme.txt 3 +poeme.txt : 6 lignes, 26 mots +Les 3 mots les plus fréquents : + la : 4 + mer : 3 + le : 2 +``` + +`Counter.total()` (Python 3.10+) renvoie la somme de tous les compteurs, c'est-à-dire le nombre total de mots. `le` et `et` apparaissent tous deux 2 fois : à égalité, `most_common` garde l'ordre de première apparition, donc `le` (ligne 2) passe avant `et` (ligne 3). Attention : `s'endort` reste un seul mot, car l'apostrophe n'est retirée qu'aux extrémités. Les messages d'erreur et `sys.exit(1)` permettent à d'autres scripts de savoir que quelque chose s'est mal passé. + +
+ +
Corrigé du mini-projet + +Fichier `mdp_outils.py` : + +```python +"""Outils de génération et d'évaluation de mots de passe.""" + +import secrets +import string + +MOTS = [ + "tigre", "nuage", "velo", "orange", "piano", "cactus", "fusee", "galaxie", + "pirate", "volcan", "biscuit", "dragon", "lune", "marmotte", "tornade", + "citron", "baleine", "chateau", "domino", "glacier", "hibou", "jungle", + "kayak", "licorne", "mangue", "neige", "olive", "pingouin", "requin", "tulipe", +] + +_aleatoire = secrets.SystemRandom() # générateur sécurisé, avec les méthodes de random + + +def generer_mot_de_passe(longueur=12, majuscules=True, chiffres=True, symboles=True): + """Renvoie un mot de passe aléatoire sécurisé. + + Il contient toujours des minuscules, et au moins un caractère + de chaque catégorie demandée. + """ + categories = [string.ascii_lowercase] + if majuscules: + categories.append(string.ascii_uppercase) + if chiffres: + categories.append(string.digits) + if symboles: + categories.append(string.punctuation) + + if longueur < max(4, len(categories)): + raise ValueError(f"longueur trop courte : {longueur} (minimum 4)") + + alphabet = "".join(categories) + caracteres = [secrets.choice(categorie) for categorie in categories] + caracteres += [secrets.choice(alphabet) for _ in range(longueur - len(caracteres))] + _aleatoire.shuffle(caracteres) # sinon les caractères obligatoires seraient au début + return "".join(caracteres) + + +def generer_phrase_de_passe(nombre_mots=4, separateur="-"): + """Renvoie une phrase de passe du type tigre-nuage-velo-orange-42.""" + mots = [secrets.choice(MOTS) for _ in range(nombre_mots)] + nombre = secrets.randbelow(90) + 10 # entre 10 et 99 + return separateur.join(mots) + separateur + str(nombre) + + +def evaluer_force(mot_de_passe): + """Renvoie (score sur 5, verdict).""" + criteres = [ + len(mot_de_passe) >= 8, + len(mot_de_passe) >= 12, + any(c.isupper() for c in mot_de_passe), + any(c.isdigit() for c in mot_de_passe), + any(c in string.punctuation for c in mot_de_passe), + ] + score = sum(criteres) # True compte pour 1, False pour 0 + if score <= 2: + verdict = "faible" + elif score == 3: + verdict = "moyen" + else: + verdict = "fort" + return score, verdict + + +if __name__ == "__main__": + assert evaluer_force("abc") == (0, "faible") + assert evaluer_force("Abcdefgh1") == (3, "moyen") + assert evaluer_force("Abcdefgh1!xyz") == (5, "fort") + + for _ in range(100): + mdp = generer_mot_de_passe(10) + assert len(mdp) == 10 + assert any(c.isupper() for c in mdp) + assert any(c.isdigit() for c in mdp) + assert any(c in string.punctuation for c in mdp) + + mdp = generer_mot_de_passe(6, majuscules=False, chiffres=False, symboles=False) + assert mdp.islower() and len(mdp) == 6 + + try: + generer_mot_de_passe(3) + except ValueError: + pass + else: + raise AssertionError("une ValueError était attendue") + + phrase = generer_phrase_de_passe(3, "_") + assert phrase.count("_") == 3 + print("mdp_outils : tous les tests sont passés.") + print("Exemple :", generer_mot_de_passe(), "/", generer_phrase_de_passe()) +``` + +Fichier `generateur.py` : + +```python +"""Menu interactif de génération de mots de passe.""" + +from collections import deque +from datetime import datetime + +import mdp_outils + +historique = deque(maxlen=5) + + +def demander_entier(message, minimum, maximum): + while True: + try: + valeur = int(input(message)) + except ValueError: + print("Merci d'entrer un nombre entier.") + continue + if minimum <= valeur <= maximum: + return valeur + print(f"Le nombre doit être entre {minimum} et {maximum}.") + + +def demander_oui_non(message): + while True: + reponse = input(f"{message} (o/n) : ").strip().lower() + if reponse in ("o", "oui"): + return True + if reponse in ("n", "non"): + return False + print("Réponds par o ou n.") + + +def afficher_et_memoriser(secret): + score, verdict = mdp_outils.evaluer_force(secret) + print(f"\n → {secret} (force : {verdict}, {score}/5)\n") + heure = datetime.now().strftime("%H:%M:%S") + historique.append(f"[{heure}] {secret}") + + +def choix_mot_de_passe(): + longueur = demander_entier("Longueur (4 à 64) : ", 4, 64) + majuscules = demander_oui_non("Majuscules ?") + chiffres = demander_oui_non("Chiffres ?") + symboles = demander_oui_non("Symboles ?") + secret = mdp_outils.generer_mot_de_passe(longueur, majuscules, chiffres, symboles) + afficher_et_memoriser(secret) + + +def choix_phrase(): + nombre_mots = demander_entier("Nombre de mots (3 à 8) : ", 3, 8) + afficher_et_memoriser(mdp_outils.generer_phrase_de_passe(nombre_mots)) + + +def choix_tester(): + mot_de_passe = input("Mot de passe à tester : ") + score, verdict = mdp_outils.evaluer_force(mot_de_passe) + print(f"Force : {verdict} ({score}/5)") + + +def choix_historique(): + if not historique: + print("Aucune génération pour l'instant.") + for ligne in historique: + print(ligne) + + +def main(): + actions = { + "1": choix_mot_de_passe, + "2": choix_phrase, + "3": choix_tester, + "4": choix_historique, + } + while True: + print("=== Générateur de mots de passe ===") + print("1. Mot de passe aléatoire") + print("2. Phrase de passe") + print("3. Tester la force d'un mot de passe") + print("4. Voir l'historique de la session") + print("5. Quitter") + choix = input("Ton choix : ").strip() + if choix == "5": + print("À bientôt !") + break + action = actions.get(choix) + if action is None: + print("Choix invalide.") + else: + action() + + +if __name__ == "__main__": + main() +``` + +Exemple de session (les mots de passe changent à chaque exécution) : + +``` +=== Générateur de mots de passe === +1. Mot de passe aléatoire +2. Phrase de passe +3. Tester la force d'un mot de passe +4. Voir l'historique de la session +5. Quitter +Ton choix : 1 +Longueur (4 à 64) : 2 +Le nombre doit être entre 4 et 64. +Longueur (4 à 64) : 16 +Majuscules ? (o/n) : o +Chiffres ? (o/n) : peut-être +Réponds par o ou n. +Chiffres ? (o/n) : o +Symboles ? (o/n) : n + + → uJbMEcdEOm7yrS9X (force : fort, 4/5) + +=== Générateur de mots de passe === +... +Ton choix : 3 +Mot de passe à tester : soleil +Force : faible (0/5) +... +Ton choix : 4 +[14:02:31] uJbMEcdEOm7yrS9X +... +Ton choix : 5 +À bientôt ! +``` + +Points importants : + +- **Séparation des rôles** : `mdp_outils.py` ne fait **aucun** `print` ni `input` (hors tests). Il est donc réutilisable dans un autre programme, une application web, des tests… `generateur.py` ne s'occupe que du dialogue avec l'utilisateur. +- Le **dictionnaire de fonctions** `actions` évite une longue chaîne de `if/elif` : les fonctions sont des valeurs comme les autres, qu'on peut ranger dans un dictionnaire puis appeler avec `action()`. +- `secrets.SystemRandom()` donne accès aux méthodes habituelles de `random` (comme `shuffle`), mais avec la source de hasard sécurisée du système. +- `sum(criteres)` fonctionne car `True` vaut 1 et `False` vaut 0. +- `deque(maxlen=5)` oublie automatiquement les générations les plus anciennes. +- La variable `_aleatoire` commence par un tiret bas : c'est la convention pour dire « usage interne au module, ne pas l'utiliser de l'extérieur ». + +
+ +--- + +## Récapitulatif + +- Un **module** est un fichier `.py` ; un **paquet** est un dossier de modules (avec un `__init__.py`). La **bibliothèque standard** est l'ensemble des modules livrés avec Python. +- `import module` puis `module.nom` (forme par défaut) ; `from module import nom` pour quelques noms fréquents ; `as` pour un alias ; jamais `import *`. +- Python cherche les modules **d'abord dans le dossier du script** : ne nomme jamais un fichier comme un module existant (`random.py`, `math.py`…). +- Un module est exécuté **une fois**, au premier import. Le code de test se place sous `if __name__ == "__main__":`, souvent dans une fonction `main()`. +- `random` : `randint` (bornes incluses), `choice`, `choices`, `sample`, `shuffle` (sur place), `seed`. Pour la sécurité : `secrets`. +- `math` : `sqrt`, `pi`, `floor`, `ceil`, `hypot`, `isclose`… et `statistics` pour moyenne, médiane, écart-type. +- `datetime` : `date`, `datetime`, `timedelta` ; `strftime` (date → texte), `strptime` (texte → date), `isoformat` / `fromisoformat`. +- `os` (variables d'environnement, système), `sys` (`argv`, `exit`, `path`) ; préfère `pathlib` pour les fichiers. +- `collections` : `Counter` (compter), `defaultdict` (regrouper), `namedtuple` (tuples nommés), `deque` (file, historique limité). +- Pour explorer : `dir()`, `help()`, et la documentation officielle . + +### Checklist de validation + +- [ ] Je connais les différentes façons d'importer et je sais laquelle utiliser par défaut. +- [ ] J'ai créé un module à moi et je l'ai importé depuis un autre fichier. +- [ ] Je sais expliquer à quoi sert `if __name__ == "__main__":`. +- [ ] Je sais tirer un nombre au hasard, piocher dans une liste et rendre le hasard reproductible. +- [ ] Je sais calculer le nombre de jours entre deux dates et formater une date en `JJ/MM/AAAA`. +- [ ] Je sais compter des éléments avec `Counter` et regrouper avec `defaultdict`. +- [ ] Je sais lire les arguments de la ligne de commande avec `sys.argv`. +- [ ] Je sais pourquoi mon fichier ne doit jamais s'appeler `random.py`. + +--- + +[← Chapitre précédent](08-erreurs-et-debogage.md) | [Sommaire](README.md) | [Chapitre suivant →](10-poo-classes-objets.md) diff --git a/docs/presentations/Python/10-poo-classes-objets.md b/docs/presentations/Python/10-poo-classes-objets.md new file mode 100644 index 0000000..d139782 --- /dev/null +++ b/docs/presentations/Python/10-poo-classes-objets.md @@ -0,0 +1,1807 @@ +# Chapitre 10 — Programmation orientée objet : classes et objets + +> **Durée estimée** : 5 à 6 heures (cours + exercices + mini-projet) +> +> **Prérequis** : [Chapitre 09 — Modules et bibliothèque standard](09-modules-bibliotheque-standard.md) (et surtout les fonctions du [chapitre 05](05-fonctions.md) et les dictionnaires du [chapitre 04](04-structures-de-donnees.md)). +> +> **Objectifs** — À la fin de ce chapitre, tu sauras : +> - expliquer ce qu'est une classe, un objet (une instance), un attribut et une méthode ; +> - écrire une classe avec un constructeur `__init__` et comprendre enfin ce qu'est `self` ; +> - ajouter des méthodes qui lisent ou modifient l'état d'un objet ; +> - rendre tes objets lisibles avec `__str__` et `__repr__` ; +> - distinguer attributs d'instance et attributs de classe, et éviter le piège de l'attribut mutable partagé ; +> - concevoir une petite classe propre : validation des données, exceptions, conventions de nommage. + +--- + +## Cours + +### 1. Pourquoi des objets ? + +Imagine que tu programmes un jeu avec des personnages. Avec ce que tu sais déjà, tu utiliserais sans doute des **dictionnaires** et des **fonctions** : + +```python +def creer_personnage(nom, points_de_vie): + return {"nom": nom, "pv": points_de_vie} + + +def subir_degats(personnage, degats): + personnage["pv"] = max(0, personnage["pv"] - degats) + + +heros = creer_personnage("Aria", 30) +subir_degats(heros, 12) +print(heros) +``` + +``` +{'nom': 'Aria', 'pv': 18} +``` + +Ça marche. Mais quand le programme grandit, des problèmes apparaissent : + +- les **données** (le dictionnaire) et les **actions** (les fonctions) sont séparées : rien n'indique que `subir_degats` est faite pour ces dictionnaires-là ; +- une faute de frappe (`heros["vp"]`) crée silencieusement une nouvelle clé ; +- rien n'empêche de passer un dictionnaire de *voiture* à `subir_degats`. + +La **programmation orientée objet** (POO) propose de **regrouper les données et les actions** qui vont ensemble dans une même entité : un **objet**. + +```python +class Personnage: + def __init__(self, nom, points_de_vie): + self.nom = nom + self.pv = points_de_vie + + def subir_degats(self, degats): + self.pv = max(0, self.pv - degats) + + +heros = Personnage("Aria", 30) +heros.subir_degats(12) +print(heros.nom, heros.pv) +``` + +``` +Aria 18 +``` + +Ne t'inquiète pas si la syntaxe te paraît étrange : tout ce chapitre sert à l'expliquer, ligne par ligne. + +> 💡 Bonne nouvelle : tu utilises des objets depuis le début ! Une chaîne est un objet de la classe `str`, avec ses méthodes `.upper()`, `.split()`… Une liste est un objet de la classe `list`, avec `.append()`, `.sort()`… Dans ce chapitre, tu apprends à **créer tes propres types**. + +```python +print(type("bonjour")) +print(type([1, 2, 3])) +print("bonjour".upper()) +``` + +``` + + +BONJOUR +``` + +### 2. Classe et objet : le moule et les gâteaux + +Deux mots à bien distinguer : + +- Une **classe** est un **plan de fabrication**, un modèle. Elle décrit ce qu'auront tous les objets de ce type. +- Un **objet** (on dit aussi une **instance**) est un exemplaire **concret**, fabriqué à partir de ce plan. + +> 💡 **Analogie** : la classe, c'est le **moule à gâteaux** ; les objets, ce sont les **gâteaux**. Un seul moule permet de fabriquer autant de gâteaux que tu veux. Ils ont tous la même forme, mais chacun peut avoir son propre parfum (ses propres valeurs). Manger un gâteau ne fait pas disparaître les autres. + +Un objet possède : + +- des **attributs** : ses données, ses caractéristiques (le nom, les points de vie…). Ce sont des variables attachées à l'objet. +- des **méthodes** : ses actions, ses comportements (subir des dégâts, se soigner…). Ce sont des fonctions attachées à l'objet. + +Une astuce de conception : dans la description d'un problème, les **noms communs** deviennent souvent des classes ou des attributs, et les **verbes** des méthodes. « Un *compte bancaire* a un *titulaire* et un *solde* ; on peut *déposer* et *retirer* de l'argent. » + +### 3. Ta première classe + +La classe la plus simple possible : + +```python +class Chien: + pass + + +rex = Chien() # on crée un objet en « appelant » la classe +medor = Chien() + +print(rex) +print(type(rex)) +print(rex is medor) +``` + +``` +<__main__.Chien object at 0x7f3a2c1d5e50> + +False +``` + +(L'adresse `0x7f...` change à chaque exécution : c'est l'emplacement de l'objet dans la mémoire.) + +- `class Chien:` définit la classe. **Convention** : un nom de classe s'écrit en **CamelCase** (`Chien`, `CompteBancaire`, `PersonnageJoueur`), alors que les variables et fonctions sont en `snake_case`. +- `Chien()` **crée** un nouvel objet. On dit qu'on **instancie** la classe. +- `rex` et `medor` sont **deux objets distincts** (`is` renvoie `False`). + +On peut techniquement ajouter des attributs à la main (`rex.nom = "Rex"`), mais ce n'est pas une bonne pratique : rien ne garantit que tous les chiens auront un nom. C'est le rôle du **constructeur**. + +### 4. Le constructeur `__init__` et les attributs + +La méthode spéciale `__init__` (deux tirets bas de chaque côté, on dit « dunder init », pour *double underscore*) est appelée **automatiquement** à la création de chaque objet. Elle sert à lui donner ses attributs de départ. + +```python +class Chien: + def __init__(self, nom, race, age): + self.nom = nom + self.race = race + self.age = age + + +rex = Chien("Rex", "berger allemand", 4) +medor = Chien("Médor", "labrador", 2) + +print(rex.nom, rex.age) +print(medor.nom, medor.race) +``` + +``` +Rex 4 +Médor labrador +``` + +Que se passe-t-il quand on écrit `Chien("Rex", "berger allemand", 4)` ? + +1. Python crée un objet `Chien` **vide**. +2. Il appelle `__init__` en lui passant **cet objet tout neuf** comme premier argument (`self`), suivi de tes arguments : `nom="Rex"`, `race="berger allemand"`, `age=4`. +3. `self.nom = nom` crée un attribut `nom` **sur l'objet**, et y range la valeur du paramètre `nom`. +4. L'objet, maintenant rempli, est renvoyé et rangé dans `rex`. + +> ⚠️ `self.nom` et `nom` sont **deux choses différentes** : `nom` est le paramètre (une variable locale qui disparaît à la fin de `__init__`), `self.nom` est l'attribut **stocké dans l'objet** (qui dure aussi longtemps que l'objet). On leur donne souvent le même nom par habitude, mais ce n'est pas obligatoire. + +On accède aux attributs avec la **notation pointée** `objet.attribut`, et on peut les modifier : + +```python +class Chien: + def __init__(self, nom, race, age): + self.nom = nom + self.race = race + self.age = age + + +rex = Chien("Rex", "berger allemand", 4) +rex.age = 5 # modification +rex.age += 1 # encore une +print(rex.age) +print(vars(rex)) # tous les attributs, sous forme de dictionnaire +``` + +``` +6 +{'nom': 'Rex', 'race': 'berger allemand', 'age': 6} +``` + +`vars(objet)` est très utile pour déboguer : il montre le contenu complet d'un objet. + +#### Valeurs par défaut et attributs calculés + +`__init__` est une fonction comme les autres : ses paramètres peuvent avoir des valeurs par défaut ([chapitre 05](05-fonctions.md)). Et tous les attributs ne viennent pas forcément d'un paramètre : + +```python +class Personnage: + def __init__(self, nom, classe="guerrier", niveau=1): + self.nom = nom + self.classe = classe + self.niveau = niveau + self.pv_max = 20 + niveau * 10 # calculé + self.pv = self.pv_max # au départ, en pleine forme + self.inventaire = [] # une liste vide, propre à CHAQUE personnage + + +aria = Personnage("Aria", "mage", niveau=3) +brutus = Personnage("Brutus") + +print(aria.classe, aria.pv) +print(brutus.classe, brutus.pv) +aria.inventaire.append("bâton") +print(aria.inventaire, brutus.inventaire) +``` + +``` +mage 50 +guerrier 30 +['bâton'] [] +``` + +Chaque objet a **sa propre liste** `inventaire`, parce qu'elle est créée **à l'intérieur** de `__init__`, donc à nouveau pour chaque personnage. Retiens ce point : il est au cœur d'un piège classique (voir la section 8). + +### 5. `self`, enfin expliqué + +`self` est le mot qui intrigue le plus les débutants. C'est pourtant simple : **`self`, c'est l'objet sur lequel on est en train de travailler**. + +Une classe est écrite **une seule fois**, mais sert pour **des milliers d'objets**. Quand le code de la classe dit « mon nom », il faut bien savoir *de quel objet* on parle. `self` est ce « moi ». + +> 💡 **Analogie** : imagine une fiche de consignes distribuée à tous les élèves d'une classe : « Écris **ton** prénom en haut de **ta** feuille ». La fiche est la même pour tous, mais chaque élève l'applique à **lui-même**. `self`, c'est le « ton / ta » de la fiche. + +Voici le point clé. Quand tu écris : + +```python +rex.aboyer() +``` + +Python le traduit **automatiquement** en : + +```python +Chien.aboyer(rex) +``` + +L'objet placé **avant le point** devient le **premier argument** de la méthode. C'est pour ça que toute méthode doit déclarer `self` comme premier paramètre, même si on ne le passe jamais soi-même entre les parenthèses. + +```python +class Chien: + def __init__(self, nom): + self.nom = nom + + def aboyer(self): + print(f"{self.nom} fait : Wouf !") + + +rex = Chien("Rex") +medor = Chien("Médor") + +rex.aboyer() # self = rex +medor.aboyer() # self = medor +Chien.aboyer(rex) # exactement équivalent à rex.aboyer() (mais on ne l'écrit jamais ainsi) +``` + +``` +Rex fait : Wouf ! +Médor fait : Wouf ! +Rex fait : Wouf ! +``` + +`self` n'est pas un mot réservé : c'est une **convention** universelle. Techniquement, tu pourrais l'appeler `moi`, mais ne le fais pas, tout le monde s'attend à lire `self`. + +### 6. Les méthodes + +Une **méthode** est une fonction définie **dans** une classe. Elle a accès à l'objet via `self`, donc à tous ses attributs et à toutes ses autres méthodes. + +On distingue en gros deux sortes de méthodes : + +- celles qui **modifient l'état** de l'objet (ses attributs) ; +- celles qui **calculent et renvoient** une information, sans rien modifier. + +```python +class Personnage: + def __init__(self, nom, pv_max): + self.nom = nom + self.pv_max = pv_max + self.pv = pv_max + + # --- méthodes qui modifient l'état --- + def subir_degats(self, degats): + self.pv = max(0, self.pv - degats) + + def se_soigner(self, soin): + self.pv = min(self.pv_max, self.pv + soin) + + # --- méthodes qui renvoient une information --- + def est_vivant(self): + return self.pv > 0 + + def barre_de_vie(self, largeur=10): + pleins = round(self.pv / self.pv_max * largeur) + return "[" + "█" * pleins + "·" * (largeur - pleins) + "]" + + # --- une méthode qui en utilise d'autres --- + def afficher(self): + etat = "debout" if self.est_vivant() else "K.O." + print(f"{self.nom:<8} {self.barre_de_vie()} {self.pv:>3}/{self.pv_max} ({etat})") + + +aria = Personnage("Aria", 40) +aria.afficher() +aria.subir_degats(25) +aria.afficher() +aria.se_soigner(100) +aria.afficher() +aria.subir_degats(999) +aria.afficher() +``` + +``` +Aria [██████████] 40/40 (debout) +Aria [████······] 15/40 (debout) +Aria [██████████] 40/40 (debout) +Aria [··········] 0/40 (K.O.) +``` + +Observe : + +- `se_soigner` ne peut pas dépasser `pv_max` grâce à `min`, `subir_degats` ne descend pas sous 0 grâce à `max`. **Les règles du jeu sont dans la classe**, à un seul endroit : impossible de les oublier ailleurs dans le programme. +- `afficher` appelle d'autres méthodes **du même objet** avec `self.est_vivant()` et `self.barre_de_vie()`. Sans `self.`, Python chercherait une fonction globale `est_vivant` et provoquerait une `NameError`. + +#### Des objets qui interagissent + +Une méthode peut recevoir **un autre objet** en paramètre. C'est là que la POO devient vraiment expressive : + +```python +class Personnage: + def __init__(self, nom, pv, force): + self.nom = nom + self.pv = pv + self.force = force + + def est_vivant(self): + return self.pv > 0 + + def attaquer(self, cible): + cible.pv = max(0, cible.pv - self.force) + print(f"{self.nom} frappe {cible.nom} : -{self.force} PV (reste {cible.pv})") + + +heros = Personnage("Aria", 30, 7) +gobelin = Personnage("Gobelin", 18, 4) + +while heros.est_vivant() and gobelin.est_vivant(): + heros.attaquer(gobelin) + if gobelin.est_vivant(): + gobelin.attaquer(heros) + +gagnant = heros if heros.est_vivant() else gobelin +print(f"Vainqueur : {gagnant.nom}") +``` + +``` +Aria frappe Gobelin : -7 PV (reste 11) +Gobelin frappe Aria : -4 PV (reste 26) +Aria frappe Gobelin : -7 PV (reste 4) +Gobelin frappe Aria : -4 PV (reste 22) +Aria frappe Gobelin : -7 PV (reste 0) +Vainqueur : Aria +``` + +Dans `attaquer`, `self` est l'attaquant et `cible` la victime : deux objets de la même classe, chacun avec ses propres attributs. Le code se lit presque comme une phrase : `heros.attaquer(gobelin)`. + +### 7. Rendre un objet lisible : `__str__` et `__repr__` + +Par défaut, afficher un objet donne un résultat peu parlant : + +```python +class Livre: + def __init__(self, titre, auteur, pages): + self.titre = titre + self.auteur = auteur + self.pages = pages + + +livre = Livre("Le Petit Prince", "Saint-Exupéry", 96) +print(livre) +``` + +``` +<__main__.Livre object at 0x7f8e0ed74cd0> +``` + +On peut définir deux méthodes spéciales pour y remédier : + +| Méthode | Appelée par | Destinée à | Style | +|---|---|---|---| +| `__str__` | `print(objet)`, `str(objet)`, f-strings | l'**utilisateur** | joli, lisible | +| `__repr__` | `repr(objet)`, la console interactive, l'affichage dans une liste | le **développeur** | précis, sans ambiguïté, idéalement du code Python valide | + +```python +class Livre: + def __init__(self, titre, auteur, pages): + self.titre = titre + self.auteur = auteur + self.pages = pages + + def __str__(self): + return f"« {self.titre} » de {self.auteur} ({self.pages} pages)" + + def __repr__(self): + return f"Livre({self.titre!r}, {self.auteur!r}, {self.pages!r})" + + +livre = Livre("Le Petit Prince", "Saint-Exupéry", 96) +print(livre) # utilise __str__ +print(f"Je lis {livre}") # __str__ aussi +print(repr(livre)) # __repr__ +bibliotheque = [livre, Livre("Dune", "Herbert", 896)] +print(bibliotheque) # une liste affiche ses éléments avec __repr__ ! +``` + +``` +« Le Petit Prince » de Saint-Exupéry (96 pages) +Je lis « Le Petit Prince » de Saint-Exupéry (96 pages) +Livre('Le Petit Prince', 'Saint-Exupéry', 96) +[Livre('Le Petit Prince', 'Saint-Exupéry', 96), Livre('Dune', 'Herbert', 896)] +``` + +Règles pratiques : + +- `__str__` et `__repr__` doivent **renvoyer** une chaîne (`return`), pas l'afficher (`print`). +- Si tu ne dois en écrire qu'une, écris `__repr__` : quand `__str__` n'existe pas, Python utilise `__repr__` à sa place. +- Le `!r` dans la f-string applique `repr()` à la valeur : il ajoute les guillemets autour des chaînes, ce qui permet de recopier la sortie comme du code Python. + +Ces méthodes à double tiret bas (`__init__`, `__str__`, `__repr__`…) s'appellent des **méthodes spéciales** (ou « méthodes magiques ») : on ne les appelle presque jamais directement, c'est Python qui les appelle pour nous au bon moment. Il en existe beaucoup d'autres, par exemple `__eq__` pour `==` ou `__len__` pour `len()`, que tu découvriras au fil du temps. + +```python +class Playlist: + def __init__(self, nom): + self.nom = nom + self.titres = [] + + def ajouter(self, titre): + self.titres.append(titre) + + def __len__(self): + return len(self.titres) + + +ma_liste = Playlist("Running") +ma_liste.ajouter("Titre A") +ma_liste.ajouter("Titre B") +print(len(ma_liste)) +``` + +``` +2 +``` + +### 8. Attributs de classe et attributs d'instance + +Jusqu'ici, tous nos attributs étaient des **attributs d'instance** : créés avec `self.xxx = ...`, propres à chaque objet. + +Un **attribut de classe** est défini directement dans le corps de la classe, en dehors de toute méthode. Il est **partagé** par tous les objets : + +```python +class Voiture: + nombre_de_roues = 4 # attribut de classe : commun à toutes les voitures + voitures_fabriquees = 0 # un compteur partagé + + def __init__(self, marque, couleur): + self.marque = marque # attributs d'instance : propres à chaque voiture + self.couleur = couleur + Voiture.voitures_fabriquees += 1 + + +v1 = Voiture("Renault", "rouge") +v2 = Voiture("Peugeot", "bleue") + +print(v1.nombre_de_roues, v2.nombre_de_roues, Voiture.nombre_de_roues) +print(Voiture.voitures_fabriquees) +print(v1.couleur, v2.couleur) +``` + +``` +4 4 4 +2 +rouge bleue +``` + +Quand tu écris `v1.nombre_de_roues`, Python cherche d'abord un attribut d'instance `nombre_de_roues` sur `v1` ; il n'en trouve pas, alors il regarde **dans la classe**. C'est pour ça que l'attribut de classe est visible depuis chaque objet. + +> ⚠️ Pour **modifier** un attribut de classe, passe par le nom de la classe (`Voiture.voitures_fabriquees += 1`). Si tu écris `self.voitures_fabriquees += 1`, Python crée un **nouvel attribut d'instance** sur cet objet, qui **masque** celui de la classe, et le compteur partagé ne bouge pas. + +**Quand utiliser un attribut de classe ?** Pour des **constantes** communes à tous les objets (`TAUX_TVA = 0.2`, `PV_MAX = 100`) ou des compteurs globaux. Pour tout le reste, utilise des attributs d'instance créés dans `__init__`. + +#### Le piège de l'attribut de classe mutable + +Ce qui suit est **l'un des bugs les plus fréquents** en POO Python : + +```python +class Panier: + articles = [] # ❌ une SEULE liste, partagée par tous les paniers ! + + def __init__(self, client): + self.client = client + + def ajouter(self, article): + self.articles.append(article) + + +panier_alice = Panier("Alice") +panier_bob = Panier("Bob") +panier_alice.ajouter("pain") +print(panier_bob.articles) # Bob n'a rien acheté… +``` + +``` +['pain'] +``` + +Le pain d'Alice apparaît dans le panier de Bob ! `self.articles.append(...)` ne **crée** pas d'attribut d'instance, il **modifie** la liste trouvée dans la classe, qui est commune à tous. ✅ Correction : crée la liste **dans `__init__`**, pour que chaque panier ait la sienne : + +```python +class Panier: + def __init__(self, client): + self.client = client + self.articles = [] # ✅ une nouvelle liste pour chaque panier + + def ajouter(self, article): + self.articles.append(article) + + +panier_alice = Panier("Alice") +panier_bob = Panier("Bob") +panier_alice.ajouter("pain") +print(panier_alice.articles, panier_bob.articles) +``` + +``` +['pain'] [] +``` + +### 9. Les objets sont des références + +Comme les listes ([chapitre 04](04-structures-de-donnees.md)), les objets sont manipulés par **référence** : une variable ne *contient* pas l'objet, elle *pointe* vers lui. + +```python +class Compteur: + def __init__(self): + self.valeur = 0 + + def incrementer(self): + self.valeur += 1 + + +a = Compteur() +b = a # b pointe vers LE MÊME objet que a (pas une copie) +b.incrementer() +print(a.valeur) +print(a is b) + +c = Compteur() # un nouvel objet +print(a is c) +``` + +``` +1 +True +False +``` + +> 💡 **Analogie** : une variable, c'est une **étiquette** collée sur un objet. `b = a` colle une deuxième étiquette sur le même objet ; il n'y a toujours qu'un seul compteur. + +Conséquence importante : quand tu passes un objet à une fonction, la fonction peut le **modifier** (c'est d'ailleurs ce que fait `attaquer` à la section 6 avec `cible`). + +Et pour comparer deux objets ? Par défaut, `==` se comporte comme `is` : deux objets distincts ne sont jamais égaux, même avec les mêmes attributs. Tu peux changer cela en définissant `__eq__` : + +```python +class Point: + def __init__(self, x, y): + self.x = x + self.y = y + + def __eq__(self, autre): + return self.x == autre.x and self.y == autre.y + + +p1 = Point(1, 2) +p2 = Point(1, 2) +print(p1 == p2, p1 is p2) +``` + +``` +True False +``` + +(Les *dataclasses* du [chapitre 11](11-poo-avancee-heritage-typage.md) écriront `__init__`, `__repr__` et `__eq__` automatiquement à ta place.) + +### 10. Protéger ses données : validation et encapsulation + +Une classe est responsable de la **cohérence** de ses objets. Un compte bancaire ne devrait jamais avoir un dépôt négatif ; un personnage ne devrait jamais avoir plus de PV que son maximum. Le bon réflexe : **valider dans les méthodes** et **lever une exception** ([chapitre 08](08-erreurs-et-debogage.md)) si la demande est invalide. + +```python +class Thermostat: + TEMPERATURE_MIN = 5 + TEMPERATURE_MAX = 30 + + def __init__(self, temperature=19): + self._temperature = 0 + self.regler(temperature) # on réutilise la validation dès la création + + def regler(self, temperature): + if not self.TEMPERATURE_MIN <= temperature <= self.TEMPERATURE_MAX: + raise ValueError( + f"température hors limites : {temperature} " + f"(entre {self.TEMPERATURE_MIN} et {self.TEMPERATURE_MAX})" + ) + self._temperature = temperature + + def temperature(self): + return self._temperature + + +salon = Thermostat() +salon.regler(21) +print(salon.temperature()) +try: + salon.regler(45) +except ValueError as erreur: + print("Refusé :", erreur) +print(salon.temperature()) +``` + +``` +21 +Refusé : température hors limites : 45 (entre 5 et 30) +21 +``` + +#### La convention du tiret bas + +Remarque le nom `_temperature`, avec un **tiret bas** au début. En Python, rien n'est vraiment « privé » : n'importe qui peut écrire `salon._temperature = 99`. Mais le tiret bas est une **convention** forte qui signifie : + +> « Attribut **interne** : ne le modifie pas directement depuis l'extérieur, passe par les méthodes prévues. » + +C'est ce qu'on appelle l'**encapsulation** : l'objet expose une interface (ses méthodes publiques, comme `regler` et `temperature`) et cache ses détails internes. On en reparle au [chapitre 11](11-poo-avancee-heritage-typage.md), où `@property` permettra d'écrire `salon.temperature` sans parenthèses tout en gardant la validation. + +> 💡 Les exceptions personnalisées que tu as écrites au [chapitre 08](08-erreurs-et-debogage.md), `class SoldeInsuffisantError(Exception): pass`, sont… des classes ! Le `(Exception)` entre parenthèses signifie que ta classe **hérite** de `Exception` : c'est le sujet du prochain chapitre. + +### 11. Des objets qui contiennent des objets + +Un attribut peut contenir n'importe quelle valeur : un nombre, une liste… ou d'autres objets. On construit ainsi des programmes entiers en assemblant des classes simples : + +```python +class Article: + def __init__(self, nom, prix, quantite=1): + self.nom = nom + self.prix = prix + self.quantite = quantite + + def sous_total(self): + return self.prix * self.quantite + + def __str__(self): + return f"{self.quantite} x {self.nom:<12} {self.sous_total():>7.2f} €" + + +class Commande: + def __init__(self, client): + self.client = client + self.articles = [] # une liste d'objets Article + + def ajouter(self, article): + self.articles.append(article) + + def total(self): + return sum(article.sous_total() for article in self.articles) + + def afficher(self): + print(f"Commande de {self.client}") + for article in self.articles: + print(" ", article) + print(f" {'TOTAL':<16} {self.total():>7.2f} €") + + +commande = Commande("Léa") +commande.ajouter(Article("Cahier", 2.50, 3)) +commande.ajouter(Article("Stylo", 1.20, 5)) +commande.ajouter(Article("Cartable", 34.90)) +commande.afficher() +``` + +``` +Commande de Léa + 3 x Cahier 7.50 € + 5 x Stylo 6.00 € + 1 x Cartable 34.90 € + TOTAL 48.40 € +``` + +Chaque classe a **une responsabilité claire** : `Article` sait calculer son sous-total et s'afficher ; `Commande` sait gérer une liste d'articles et calculer le total. Cette façon d'assembler des objets s'appelle la **composition**, approfondie au [chapitre 11](11-poo-avancee-heritage-typage.md). + +### 12. Ranger ses classes dans des modules + +Une classe s'importe comme une fonction ([chapitre 09](09-modules-bibliotheque-standard.md)). Une organisation courante : un module par classe importante, ou par groupe de classes liées. + +``` +boutique/ +├── commande.py ← contient les classes Article et Commande +└── main.py +``` + +```python +# main.py +from commande import Article, Commande + +panier = Commande("Hugo") +panier.ajouter(Article("Gomme", 0.80, 2)) +panier.afficher() +``` + +### 13. Récapitulatif du vocabulaire + +| Terme | Signification | Exemple | +|---|---|---| +| Classe | Le modèle, le plan | `class Chien:` | +| Objet / instance | Un exemplaire concret | `rex = Chien("Rex")` | +| Instancier | Créer un objet à partir d'une classe | `Chien("Rex")` | +| Attribut | Une donnée attachée à l'objet | `rex.nom` | +| Méthode | Une fonction attachée à l'objet | `rex.aboyer()` | +| Constructeur | La méthode qui initialise l'objet | `__init__` | +| `self` | L'objet courant, à l'intérieur de la classe | `self.nom = nom` | +| Méthode spéciale | Méthode appelée par Python lui-même | `__str__`, `__repr__`, `__len__` | +| Attribut de classe | Donnée partagée par toutes les instances | `Voiture.nombre_de_roues` | + +--- + +## Pièges fréquents + +### Piège 1 — Oublier `self` dans la définition d'une méthode + +```python +class Chien: + def aboyer(): + print("Wouf") + + +Chien().aboyer() +``` + +``` +TypeError: Chien.aboyer() takes 0 positional arguments but 1 was given +``` + +Le message semble absurde : « 0 argument attendu, mais 1 donné », alors qu'on n'a rien mis entre les parenthèses ! Souviens-toi : `objet.methode()` passe **automatiquement** l'objet en premier argument. ✅ Toute méthode commence par `self` : `def aboyer(self):`. + +### Piège 2 — Oublier `self.` devant un attribut + +```python +class Chien: + def __init__(self, nom): + self.nom = nom + + def presenter(self): + print(f"Je suis {nom}") + + +Chien("Rex").presenter() +``` + +``` +NameError: name 'nom' is not defined. Did you mean: 'self.nom'? +``` + +Dans `presenter`, il n'existe aucune variable locale `nom` : l'information est rangée dans l'objet. Python te souffle même la solution. ✅ `print(f"Je suis {self.nom}")`. + +Variante plus sournoise, **sans** message d'erreur immédiat : + +```python +class Chien: + def __init__(self, nom): + nom = nom # ❌ ne fait rien d'utile : affecte le paramètre à lui-même + + +print(Chien("Rex").nom) +``` + +``` +AttributeError: 'Chien' object has no attribute 'nom' +``` + +✅ `self.nom = nom`. + +### Piège 3 — Mal écrire `__init__` + +```python +class Chien: + def _init_(self, nom): # un seul tiret bas de chaque côté ! + self.nom = nom + + +Chien("Rex") +``` + +``` +TypeError: Chien() takes no arguments +``` + +Python ne reconnaît pas `_init_` comme constructeur : pour lui, la classe n'a pas de `__init__`, donc elle n'accepte aucun argument. Même symptôme avec `__int__` ou `__inti__`. ✅ **Deux** tirets bas de chaque côté : `__init__`. + +### Piège 4 — L'attribut de classe mutable partagé + +Déjà vu à la section 8, mais il mérite sa place ici tant il est fréquent : + +```python +class Panier: + articles = [] # ❌ partagé par tous les paniers + + def ajouter(self, article): + self.articles.append(article) +``` + +✅ Toute liste, dictionnaire ou ensemble propre à chaque objet doit être créé **dans `__init__`** : `self.articles = []`. + +Même piège avec une **valeur par défaut mutable** dans `__init__` (vu au [chapitre 05](05-fonctions.md)) : + +```python +class Panier: + def __init__(self, client, articles=[]): # ❌ la liste par défaut est créée UNE fois + self.client = client + self.articles = articles + + +panier_alice = Panier("Alice") +panier_bob = Panier("Bob") +panier_alice.articles.append("lait") +print(panier_bob.articles) +``` + +``` +['lait'] +``` + +✅ Utilise `None` comme valeur par défaut : + +```python +class Panier: + def __init__(self, client, articles=None): + self.client = client + self.articles = articles if articles is not None else [] +``` + +### Piège 5 — `__str__` qui affiche au lieu de renvoyer + +```python +class Chien: + def __init__(self, nom): + self.nom = nom + + def __str__(self): + print(f"Chien {self.nom}") # ❌ print au lieu de return + + +print(Chien("Rex")) +``` + +``` +Chien Rex +Traceback (most recent call last): + ... +TypeError: __str__ returned non-string (type NoneType) +``` + +✅ `return f"Chien {self.nom}"`. + +### Piège 6 — Oublier les parenthèses en appelant une méthode + +```python +class Chien: + def __init__(self, nom): + self.nom = nom + + def aboyer(self): + return "Wouf" + + +rex = Chien("Rex") +print(rex.aboyer) +``` + +``` +> +``` + +Pas d'erreur, mais on affiche **la méthode elle-même** au lieu de l'appeler. Piège redoutable dans un `if` : `if rex.est_vivant:` est **toujours vrai** (une méthode n'est jamais « vide »). ✅ `rex.aboyer()`. + +### Piège 7 — Appeler une méthode sur la classe au lieu d'un objet + +```python +class Chien: + def __init__(self, nom): + self.nom = nom + + def aboyer(self): + return "Wouf" + + +Chien.aboyer() +``` + +``` +TypeError: Chien.aboyer() missing 1 required positional argument: 'self' +``` + +Une méthode ordinaire a besoin d'un **objet** pour travailler. ✅ Crée d'abord un objet : `Chien("Rex").aboyer()`. (Le [chapitre 11](11-poo-avancee-heritage-typage.md) présente les méthodes de classe et statiques, qui s'appellent sur la classe.) + +### Piège 8 — Oublier un argument du constructeur + +```python +class Chien: + def __init__(self, nom): + self.nom = nom + + +Chien() +``` + +``` +TypeError: Chien.__init__() missing 1 required positional argument: 'nom' +``` + +Les arguments de `Chien(...)` sont transmis à `__init__` (sauf `self`, fourni automatiquement). ✅ `Chien("Rex")`, ou une valeur par défaut dans `__init__` si c'est pertinent. + +--- + +## Exercices + +### Exercice 1 ⭐ — Rectangle + +Écris une classe `Rectangle` avec deux attributs `largeur` et `hauteur`, et les méthodes : + +- `aire()` et `perimetre()` ; +- `est_carre()` qui renvoie `True` si les deux côtés sont égaux ; +- `__str__` qui renvoie par exemple `Rectangle 4 x 3`. + +```python +r = Rectangle(4, 3) +print(r) # Rectangle 4 x 3 +print(r.aire(), r.perimetre()) # 12 14 +print(r.est_carre()) # False +print(Rectangle(5, 5).est_carre()) # True +``` + +### Exercice 2 ⭐ — Compteur de clics + +Écris une classe `Compteur` avec : + +- un attribut `valeur` qui vaut 0 à la création (ou une valeur de départ optionnelle) ; +- `incrementer(pas=1)`, `decrementer(pas=1)` (la valeur ne descend jamais sous 0) et `reinitialiser()` ; +- `__repr__` qui renvoie `Compteur(valeur=...)`. + +```python +c = Compteur() +c.incrementer() +c.incrementer(5) +c.decrementer(2) +print(c) # Compteur(valeur=4) +c.decrementer(10) +print(c.valeur) # 0 +c2 = Compteur(10) +print(c2) # Compteur(valeur=10) +``` + +### Exercice 3 ⭐ — `__str__` et `__repr__` + +Écris une classe `Film` (titre, réalisateur, année, durée en minutes) avec : + +- `__str__` → `Inception (2010), de Christopher Nolan — 2h28` ; +- `__repr__` → `Film('Inception', 'Christopher Nolan', 2010, 148)`. + +Crée une liste de trois films, affiche la liste entière avec `print(films)`, puis chaque film avec une boucle. Explique la différence entre les deux affichages. + +### Exercice 4 ⭐⭐ — Le point qui se déplace + +Écris une classe `Point` (coordonnées `x` et `y`) avec : + +- `deplacer(dx, dy)` qui modifie le point ; +- `distance(autre)` qui renvoie la distance avec un autre `Point` (module `math`) ; +- `milieu(autre)` qui **renvoie un nouveau** `Point` situé au milieu des deux ; +- `__repr__` → `Point(3, 4)` ; +- `__eq__` pour que deux points de mêmes coordonnées soient égaux. + +```python +a = Point(0, 0) +b = Point(3, 4) +print(a.distance(b)) # 5.0 +print(a.milieu(b)) # Point(1.5, 2.0) +a.deplacer(3, 4) +print(a == b) # True +print(a is b) # False +``` + +### Exercice 5 ⭐⭐ — Pile de tâches et attribut de classe + +Écris une classe `Tache` avec un titre, une priorité (1 à 3) et un état `terminee` (False au départ). Chaque tâche reçoit automatiquement un **numéro unique** (1, 2, 3…), grâce à un **attribut de classe** qui compte les tâches créées. + +- `terminer()` passe la tâche à l'état terminé ; +- `__str__` → `#1 [ ] Faire les courses (priorité 2)` (ou `[x]` si terminée) ; +- la priorité doit être entre 1 et 3, sinon `ValueError`. + +```python +t1 = Tache("Faire les courses", 2) +t2 = Tache("Réviser Python", 1) +t2.terminer() +print(t1) +print(t2) +print(Tache.compteur) +``` + +``` +#1 [ ] Faire les courses (priorité 2) +#2 [x] Réviser Python (priorité 1) +2 +``` + +### Exercice 6 ⭐⭐ — Le tamagotchi + +Écris une classe `Tamagotchi` avec un nom, une `faim` et un `ennui`, tous deux entre 0 (parfait) et 10 (critique), qui démarrent à 3. Méthodes : + +- `nourrir()` : faim − 4 ; `jouer()` : ennui − 4 (sans descendre sous 0) ; +- `passer_le_temps()` : faim + 2 et ennui + 1 (sans dépasser 10) ; +- `humeur()` qui renvoie `"heureux"` si la somme faim + ennui est ≤ 5, `"grognon"` si elle est ≤ 12, `"malheureux"` sinon ; +- `__str__` → `Pixel : faim 3/10, ennui 3/10, humeur : grognon`. + +Écris un petit programme qui fait vivre l'animal sur une liste d'actions : + +```python +actions = ["temps", "temps", "nourrir", "jouer", "temps", "temps", "temps", "temps"] +``` + +``` +Pixel : faim 3/10, ennui 3/10, humeur : grognon +temps -> Pixel : faim 5/10, ennui 4/10, humeur : grognon +temps -> Pixel : faim 7/10, ennui 5/10, humeur : grognon +nourrir -> Pixel : faim 3/10, ennui 5/10, humeur : grognon +jouer -> Pixel : faim 3/10, ennui 1/10, humeur : heureux +temps -> Pixel : faim 5/10, ennui 2/10, humeur : grognon +temps -> Pixel : faim 7/10, ennui 3/10, humeur : grognon +temps -> Pixel : faim 9/10, ennui 4/10, humeur : malheureux +temps -> Pixel : faim 10/10, ennui 5/10, humeur : malheureux +``` + +### Exercice 7 ⭐⭐⭐ — Carnet de contacts + +Écris deux classes : + +- `Contact` : nom, téléphone, email (optionnel). `__str__` → `Alice Martin — 06 12 34 56 78 — alice@mail.fr` (sans la partie email si elle est absente). +- `Carnet` : contient une **liste** de `Contact`. Méthodes : + - `ajouter(contact)` : refuse un doublon de nom (insensible à la casse) en levant une `ValueError` ; + - `chercher(texte)` : renvoie la liste des contacts dont le nom contient `texte` (insensible à la casse) ; + - `supprimer(nom)` : supprime le contact, ou lève une `KeyError` s'il n'existe pas ; + - `__len__` ; + - `afficher()` : affiche les contacts triés par nom. + +```python +carnet = Carnet() +carnet.ajouter(Contact("Alice Martin", "06 12 34 56 78", "alice@mail.fr")) +carnet.ajouter(Contact("Bob Durand", "07 98 76 54 32")) +carnet.ajouter(Contact("Alicia Keys", "06 00 00 00 00")) +print(len(carnet)) +for contact in carnet.chercher("ali"): + print(contact) +carnet.supprimer("bob durand") +carnet.afficher() +``` + +``` +3 +Alice Martin — 06 12 34 56 78 — alice@mail.fr +Alicia Keys — 06 00 00 00 00 +Alice Martin — 06 12 34 56 78 — alice@mail.fr +Alicia Keys — 06 00 00 00 00 +``` + +### Exercice 8 ⭐⭐⭐ — Sauvegarder des objets en JSON + +Reprends la classe `Tache` de l'exercice 5 (sans le compteur automatique : le numéro est passé au constructeur) et ajoute : + +- une méthode `vers_dict()` qui renvoie un dictionnaire des attributs ; +- une fonction (hors de la classe) `depuis_dict(donnees)` qui crée une `Tache` à partir d'un tel dictionnaire. + +Puis écris `sauvegarder(taches, chemin)` et `charger(chemin)` qui enregistrent / relisent une liste de tâches dans un fichier JSON ([chapitre 07](07-fichiers-csv-json.md)). Vérifie qu'après un aller-retour, les tâches sont identiques. + +```python +taches = [Tache(1, "Courses", 2), Tache(2, "Sport", 3, terminee=True)] +sauvegarder(taches, "taches.json") +for tache in charger("taches.json"): + print(tache) +``` + +``` +#1 [ ] Courses (priorité 2) +#2 [x] Sport (priorité 3) +``` + +--- + +## Mini-projet — Compte bancaire avec historique + +### Cahier des charges + +Tu vas modéliser un petit système bancaire, dans un fichier `banque.py`. + +**Classe `Operation`** : représente une opération passée. + +- Attributs : `type_operation` (`"dépôt"`, `"retrait"`, `"virement émis"`, `"virement reçu"`), `montant`, `date` (un `datetime`), `solde_apres`, `libelle` (optionnel). +- `__str__` → `14/03/2026 10:32 retrait -50.00 € solde : 150.00 € (Courses)`. + +**Classe `CompteBancaire`** : + +1. Création avec un titulaire et un solde initial optionnel (0 par défaut). Chaque compte reçoit un **numéro unique** automatique, du type `FR-0001`, `FR-0002`… (attribut de classe). +2. `deposer(montant, libelle="")` et `retirer(montant, libelle="")`. +3. `virer(montant, destinataire, libelle="")` : transfère de l'argent vers un **autre objet** `CompteBancaire`. Le virement apparaît dans l'historique **des deux comptes**. +4. Un **découvert autorisé** (attribut, 0 par défaut) : un retrait ne peut pas faire descendre le solde en dessous de `-decouvert_autorise`. +5. Validation : + - montant ≤ 0 → `MontantInvalideError` ; + - solde insuffisant → `SoldeInsuffisantError` (avec le solde disponible dans le message) ; + - virement vers soi-même → `ValueError`. + Les deux exceptions personnalisées héritent d'une exception `BanqueError` (syntaxe vue au [chapitre 08](08-erreurs-et-debogage.md)). +6. Le solde est stocké dans un attribut « interne » `_solde`, consultable via une méthode `solde()`. +7. L'historique est une liste d'`Operation`. Méthodes `afficher_releve()` (avec le solde actuel) et `total_par_type()` qui renvoie un dictionnaire `{type: total}`. +8. `__str__` → `Compte FR-0001 (Alice) : 150.00 €`, `__repr__` → `CompteBancaire('Alice', solde=150.0)`. + +Un programme de démonstration (sous `if __name__ == "__main__":`) crée deux comptes, effectue des opérations valides **et invalides** (interceptées proprement), puis affiche les relevés. + +### Étapes suggérées + +1. Écris les trois exceptions. +2. Écris `Operation` avec son `__str__` (teste-la seule, avec une date fixe). +3. Écris `CompteBancaire` avec `__init__`, `solde()`, `__str__`, `__repr__` et le compteur de numéros. +4. Ajoute une méthode **interne** `_enregistrer(type_operation, montant, libelle)` qui crée une `Operation` et l'ajoute à l'historique : toutes les autres méthodes l'utiliseront. +5. Ajoute `deposer`, puis `retirer` avec le découvert, puis `virer` en **réutilisant** `retirer` et `deposer`… attention, les types d'opération doivent être « virement émis / reçu » : prévois un paramètre pour cela. +6. Ajoute `afficher_releve` et `total_par_type` (un `defaultdict` est idéal). +7. Écris la démonstration et teste toutes les erreurs. + +### Pistes d'amélioration + +- Une classe `Banque` qui contient tous les comptes, avec recherche par numéro et un menu interactif. +- Sauvegarde et chargement des comptes en JSON (dates au format ISO). +- Filtrer le relevé par période ou par type d'opération. +- Calculer des intérêts mensuels sur un compte épargne (ce sera un bon exercice d'**héritage** au chapitre suivant !). + +--- + +## Corrigés + +
Corrigé de l'exercice 1 + +```python +class Rectangle: + def __init__(self, largeur, hauteur): + self.largeur = largeur + self.hauteur = hauteur + + def aire(self): + return self.largeur * self.hauteur + + def perimetre(self): + return 2 * (self.largeur + self.hauteur) + + def est_carre(self): + return self.largeur == self.hauteur + + def __str__(self): + return f"Rectangle {self.largeur} x {self.hauteur}" + + +r = Rectangle(4, 3) +print(r) +print(r.aire(), r.perimetre()) +print(r.est_carre()) +print(Rectangle(5, 5).est_carre()) +``` + +``` +Rectangle 4 x 3 +12 14 +False +True +``` + +`est_carre` renvoie directement le résultat de la comparaison, qui est déjà un booléen : inutile d'écrire `if ...: return True else: return False`. + +
+ +
Corrigé de l'exercice 2 + +```python +class Compteur: + def __init__(self, valeur=0): + self.valeur = valeur + + def incrementer(self, pas=1): + self.valeur += pas + + def decrementer(self, pas=1): + self.valeur = max(0, self.valeur - pas) + + def reinitialiser(self): + self.valeur = 0 + + def __repr__(self): + return f"Compteur(valeur={self.valeur})" + + +c = Compteur() +c.incrementer() +c.incrementer(5) +c.decrementer(2) +print(c) +c.decrementer(10) +print(c.valeur) +c2 = Compteur(10) +print(c2) +``` + +``` +Compteur(valeur=4) +0 +Compteur(valeur=10) +``` + +On n'a défini que `__repr__` : `print` l'utilise faute de `__str__`. `max(0, ...)` empêche de descendre sous zéro. + +
+ +
Corrigé de l'exercice 3 + +```python +class Film: + def __init__(self, titre, realisateur, annee, duree): + self.titre = titre + self.realisateur = realisateur + self.annee = annee + self.duree = duree + + def __str__(self): + heures, minutes = divmod(self.duree, 60) + return f"{self.titre} ({self.annee}), de {self.realisateur} — {heures}h{minutes:02d}" + + def __repr__(self): + return f"Film({self.titre!r}, {self.realisateur!r}, {self.annee}, {self.duree})" + + +films = [ + Film("Inception", "Christopher Nolan", 2010, 148), + Film("Amélie", "Jean-Pierre Jeunet", 2001, 122), + Film("Le Voyage de Chihiro", "Hayao Miyazaki", 2001, 125), +] +print(films) +for film in films: + print(film) +``` + +``` +[Film('Inception', 'Christopher Nolan', 2010, 148), Film('Amélie', 'Jean-Pierre Jeunet', 2001, 122), Film('Le Voyage de Chihiro', 'Hayao Miyazaki', 2001, 125)] +Inception (2010), de Christopher Nolan — 2h28 +Amélie (2001), de Jean-Pierre Jeunet — 2h02 +Le Voyage de Chihiro (2001), de Hayao Miyazaki — 2h05 +``` + +Afficher une **liste** utilise le `__repr__` de chaque élément (vue « développeur », précise) ; afficher **un** objet avec `print` utilise son `__str__` (vue « utilisateur », jolie). `{minutes:02d}` affiche `02` plutôt que `2`. + +
+ +
Corrigé de l'exercice 4 + +```python +import math + + +class Point: + def __init__(self, x, y): + self.x = x + self.y = y + + def deplacer(self, dx, dy): + self.x += dx + self.y += dy + + def distance(self, autre): + return math.dist((self.x, self.y), (autre.x, autre.y)) + + def milieu(self, autre): + return Point((self.x + autre.x) / 2, (self.y + autre.y) / 2) + + def __eq__(self, autre): + return self.x == autre.x and self.y == autre.y + + def __repr__(self): + return f"Point({self.x}, {self.y})" + + +a = Point(0, 0) +b = Point(3, 4) +print(a.distance(b)) +print(a.milieu(b)) +a.deplacer(3, 4) +print(a == b) +print(a is b) +``` + +``` +5.0 +Point(1.5, 2.0) +True +False +``` + +Deux styles de méthodes cohabitent : `deplacer` **modifie** l'objet (et ne renvoie rien), `milieu` **ne modifie rien** et renvoie un **nouvel** objet. Une méthode peut tout à fait créer un objet de sa propre classe. Après `deplacer`, `a` et `b` sont égaux (`==` utilise `__eq__`) mais restent deux objets distincts (`is`). + +
+ +
Corrigé de l'exercice 5 + +```python +class Tache: + compteur = 0 # attribut de classe : nombre de tâches créées + + def __init__(self, titre, priorite): + if priorite not in (1, 2, 3): + raise ValueError(f"priorité invalide : {priorite} (1, 2 ou 3)") + Tache.compteur += 1 + self.numero = Tache.compteur + self.titre = titre + self.priorite = priorite + self.terminee = False + + def terminer(self): + self.terminee = True + + def __str__(self): + case = "x" if self.terminee else " " + return f"#{self.numero} [{case}] {self.titre} (priorité {self.priorite})" + + +t1 = Tache("Faire les courses", 2) +t2 = Tache("Réviser Python", 1) +t2.terminer() +print(t1) +print(t2) +print(Tache.compteur) + +try: + Tache("Tâche impossible", 7) +except ValueError as erreur: + print("Erreur :", erreur) +print(Tache.compteur) +``` + +``` +#1 [ ] Faire les courses (priorité 2) +#2 [x] Réviser Python (priorité 1) +2 +Erreur : priorité invalide : 7 (1, 2 ou 3) +2 +``` + +On écrit bien `Tache.compteur += 1` (et non `self.compteur += 1`, qui créerait un attribut d'instance). La validation est faite **avant** d'incrémenter le compteur : une tâche refusée ne « consomme » pas de numéro. + +
+ +
Corrigé de l'exercice 6 + +```python +class Tamagotchi: + MINIMUM = 0 + MAXIMUM = 10 + + def __init__(self, nom): + self.nom = nom + self.faim = 3 + self.ennui = 3 + + def _borner(self, valeur): + """Ramène une valeur entre MINIMUM et MAXIMUM (méthode interne).""" + return max(self.MINIMUM, min(self.MAXIMUM, valeur)) + + def nourrir(self): + self.faim = self._borner(self.faim - 4) + + def jouer(self): + self.ennui = self._borner(self.ennui - 4) + + def passer_le_temps(self): + self.faim = self._borner(self.faim + 2) + self.ennui = self._borner(self.ennui + 1) + + def humeur(self): + total = self.faim + self.ennui + if total <= 5: + return "heureux" + if total <= 12: + return "grognon" + return "malheureux" + + def __str__(self): + return (f"{self.nom} : faim {self.faim}/10, ennui {self.ennui}/10, " + f"humeur : {self.humeur()}") + + +pixel = Tamagotchi("Pixel") +print(pixel) +actions = ["temps", "temps", "nourrir", "jouer", "temps", "temps", "temps", "temps"] +for action in actions: + match action: + case "nourrir": + pixel.nourrir() + case "jouer": + pixel.jouer() + case "temps": + pixel.passer_le_temps() + print(f"{action:<7} -> {pixel}") +``` + +``` +Pixel : faim 3/10, ennui 3/10, humeur : grognon +temps -> Pixel : faim 5/10, ennui 4/10, humeur : grognon +temps -> Pixel : faim 7/10, ennui 5/10, humeur : grognon +nourrir -> Pixel : faim 3/10, ennui 5/10, humeur : grognon +jouer -> Pixel : faim 3/10, ennui 1/10, humeur : heureux +temps -> Pixel : faim 5/10, ennui 2/10, humeur : grognon +temps -> Pixel : faim 7/10, ennui 3/10, humeur : grognon +temps -> Pixel : faim 9/10, ennui 4/10, humeur : malheureux +temps -> Pixel : faim 10/10, ennui 5/10, humeur : malheureux +``` + +La méthode interne `_borner` (tiret bas = usage interne) évite de répéter `max(0, min(10, ...))` partout. `__str__` appelle `self.humeur()` : l'humeur n'est pas stockée, elle est **calculée** à la demande, donc toujours à jour. + +
+ +
Corrigé de l'exercice 7 + +```python +class Contact: + def __init__(self, nom, telephone, email=""): + self.nom = nom + self.telephone = telephone + self.email = email + + def __str__(self): + texte = f"{self.nom} — {self.telephone}" + if self.email: + texte += f" — {self.email}" + return texte + + +class Carnet: + def __init__(self): + self.contacts = [] + + def _trouver(self, nom): + """Renvoie le contact portant ce nom (casse ignorée), ou None.""" + for contact in self.contacts: + if contact.nom.lower() == nom.lower(): + return contact + return None + + def ajouter(self, contact): + if self._trouver(contact.nom) is not None: + raise ValueError(f"le contact {contact.nom!r} existe déjà") + self.contacts.append(contact) + + def chercher(self, texte): + texte = texte.lower() + return [contact for contact in self.contacts if texte in contact.nom.lower()] + + def supprimer(self, nom): + contact = self._trouver(nom) + if contact is None: + raise KeyError(nom) + self.contacts.remove(contact) + + def __len__(self): + return len(self.contacts) + + def afficher(self): + for contact in sorted(self.contacts, key=lambda c: c.nom.lower()): + print(contact) + + +carnet = Carnet() +carnet.ajouter(Contact("Alice Martin", "06 12 34 56 78", "alice@mail.fr")) +carnet.ajouter(Contact("Bob Durand", "07 98 76 54 32")) +carnet.ajouter(Contact("Alicia Keys", "06 00 00 00 00")) +print(len(carnet)) +for contact in carnet.chercher("ali"): + print(contact) +carnet.supprimer("bob durand") +carnet.afficher() + +try: + carnet.ajouter(Contact("ALICE MARTIN", "01 01 01 01 01")) +except ValueError as erreur: + print("Refusé :", erreur) +try: + carnet.supprimer("Zoé") +except KeyError as erreur: + print("Introuvable :", erreur) +``` + +``` +3 +Alice Martin — 06 12 34 56 78 — alice@mail.fr +Alicia Keys — 06 00 00 00 00 +Alice Martin — 06 12 34 56 78 — alice@mail.fr +Alicia Keys — 06 00 00 00 00 +Refusé : le contact 'ALICE MARTIN' existe déjà +Introuvable : 'Zoé' +``` + +La méthode interne `_trouver` est utilisée à la fois par `ajouter` et `supprimer` : on ne répète pas la logique de recherche. `sorted(..., key=lambda c: c.nom.lower())` trie des **objets** selon un de leurs attributs ([chapitre 06](06-chaines-et-comprehensions.md)). + +
+ +
Corrigé de l'exercice 8 + +```python +import json +from pathlib import Path + + +class Tache: + def __init__(self, numero, titre, priorite, terminee=False): + self.numero = numero + self.titre = titre + self.priorite = priorite + self.terminee = terminee + + def vers_dict(self): + return { + "numero": self.numero, + "titre": self.titre, + "priorite": self.priorite, + "terminee": self.terminee, + } + + def __str__(self): + case = "x" if self.terminee else " " + return f"#{self.numero} [{case}] {self.titre} (priorité {self.priorite})" + + +def depuis_dict(donnees): + return Tache(donnees["numero"], donnees["titre"], donnees["priorite"], donnees["terminee"]) + + +def sauvegarder(taches, chemin): + donnees = [tache.vers_dict() for tache in taches] + Path(chemin).write_text(json.dumps(donnees, indent=2, ensure_ascii=False), encoding="utf-8") + + +def charger(chemin): + donnees = json.loads(Path(chemin).read_text(encoding="utf-8")) + return [depuis_dict(element) for element in donnees] + + +taches = [Tache(1, "Courses", 2), Tache(2, "Sport", 3, terminee=True)] +sauvegarder(taches, "taches.json") +relues = charger("taches.json") +for tache in relues: + print(tache) + +assert [t.vers_dict() for t in relues] == [t.vers_dict() for t in taches] +print(Path("taches.json").read_text(encoding="utf-8")) +``` + +``` +#1 [ ] Courses (priorité 2) +#2 [x] Sport (priorité 3) +[ + { + "numero": 1, + "titre": "Courses", + "priorite": 2, + "terminee": false + }, + { + "numero": 2, + "titre": "Sport", + "priorite": 3, + "terminee": true + } +] +``` + +JSON ne sait pas stocker des objets Python : on les **convertit** en dictionnaires (qui, eux, sont compatibles), puis on refait le chemin inverse au chargement. Ce schéma « objet ↔ dictionnaire » est omniprésent (applications web, bases de données…). Pour comparer, on compare les dictionnaires, puisque nos `Tache` n'ont pas de `__eq__`. Au [chapitre 11](11-poo-avancee-heritage-typage.md), `depuis_dict` deviendra une **méthode de classe** (`Tache.depuis_dict(...)`). + +
+ +
Corrigé du mini-projet + +```python +"""Comptes bancaires avec historique des opérations.""" + +from collections import defaultdict +from datetime import datetime + + +class BanqueError(Exception): + pass + + +class MontantInvalideError(BanqueError): + pass + + +class SoldeInsuffisantError(BanqueError): + pass + + +class Operation: + def __init__(self, type_operation, montant, solde_apres, libelle="", date=None): + self.type_operation = type_operation + self.montant = montant # positif pour un crédit, négatif pour un débit + self.solde_apres = solde_apres + self.libelle = libelle + self.date = date if date is not None else datetime.now() + + def __str__(self): + texte = (f"{self.date:%d/%m/%Y %H:%M} {self.type_operation:<15} " + f"{self.montant:>+9.2f} € solde : {self.solde_apres:>8.2f} €") + if self.libelle: + texte += f" ({self.libelle})" + return texte + + +class CompteBancaire: + _dernier_numero = 0 # attribut de classe : compteur partagé + + def __init__(self, titulaire, solde_initial=0.0, decouvert_autorise=0.0): + CompteBancaire._dernier_numero += 1 + self.numero = f"FR-{CompteBancaire._dernier_numero:04d}" + self.titulaire = titulaire + self.decouvert_autorise = decouvert_autorise + self._solde = 0.0 + self.historique = [] + if solde_initial > 0: + self.deposer(solde_initial, "Dépôt initial") + + # ---------- consultation ---------- + def solde(self): + return self._solde + + def __str__(self): + return f"Compte {self.numero} ({self.titulaire}) : {self._solde:.2f} €" + + def __repr__(self): + return f"CompteBancaire({self.titulaire!r}, solde={self._solde!r})" + + # ---------- méthodes internes ---------- + @staticmethod + def _verifier_montant(montant): + if montant <= 0: + raise MontantInvalideError(f"le montant doit être positif (reçu : {montant})") + + def _enregistrer(self, type_operation, montant, libelle): + self._solde += montant + self.historique.append(Operation(type_operation, montant, self._solde, libelle)) + + # ---------- opérations ---------- + def deposer(self, montant, libelle="", type_operation="dépôt"): + self._verifier_montant(montant) + self._enregistrer(type_operation, montant, libelle) + + def retirer(self, montant, libelle="", type_operation="retrait"): + self._verifier_montant(montant) + disponible = self._solde + self.decouvert_autorise + if montant > disponible: + raise SoldeInsuffisantError( + f"retrait de {montant:.2f} € refusé, disponible : {disponible:.2f} €" + ) + self._enregistrer(type_operation, -montant, libelle) + + def virer(self, montant, destinataire, libelle=""): + if destinataire is self: + raise ValueError("impossible de faire un virement vers le même compte") + # retirer d'abord : s'il échoue, rien n'est déposé chez le destinataire + self.retirer(montant, f"vers {destinataire.titulaire} {libelle}".strip(), + type_operation="virement émis") + destinataire.deposer(montant, f"de {self.titulaire} {libelle}".strip(), + type_operation="virement reçu") + + # ---------- rapports ---------- + def total_par_type(self): + totaux = defaultdict(float) + for operation in self.historique: + totaux[operation.type_operation] += operation.montant + return dict(totaux) + + def afficher_releve(self): + print(f"=== Relevé du compte {self.numero} — {self.titulaire} ===") + if not self.historique: + print("(aucune opération)") + for operation in self.historique: + print(operation) + print(f"Solde actuel : {self._solde:.2f} €") + print() + + +if __name__ == "__main__": + alice = CompteBancaire("Alice", 200) + bob = CompteBancaire("Bob", decouvert_autorise=100) + print(alice) + print(bob) + print(repr(alice)) + print() + + operations = [ + ("Alice retire 50 €", lambda: alice.retirer(50, "Courses")), + ("Bob retire 80 € (découvert)", lambda: bob.retirer(80, "Loyer")), + ("Bob retire 50 € de plus", lambda: bob.retirer(50)), + ("Alice dépose -10 €", lambda: alice.deposer(-10)), + ("Alice vire 120 € à Bob", lambda: alice.virer(120, bob, "Remboursement")), + ("Alice vire 500 € à Bob", lambda: alice.virer(500, bob)), + ("Alice se vire 10 €", lambda: alice.virer(10, alice)), + ] + for description, action in operations: + try: + action() + except BanqueError as erreur: + print(f"✗ {description} : {type(erreur).__name__} — {erreur}") + except ValueError as erreur: + print(f"✗ {description} : {erreur}") + else: + print(f"✓ {description}") + print() + + alice.afficher_releve() + bob.afficher_releve() + print("Totaux d'Alice :", alice.total_par_type()) +``` + +Sortie (les dates et heures affichées seront celles du moment de l'exécution) : + +``` +Compte FR-0001 (Alice) : 200.00 € +Compte FR-0002 (Bob) : 0.00 € +CompteBancaire('Alice', solde=200.0) + +✓ Alice retire 50 € +✓ Bob retire 80 € (découvert) +✗ Bob retire 50 € de plus : SoldeInsuffisantError — retrait de 50.00 € refusé, disponible : 20.00 € +✗ Alice dépose -10 € : MontantInvalideError — le montant doit être positif (reçu : -10) +✓ Alice vire 120 € à Bob +✗ Alice vire 500 € à Bob : SoldeInsuffisantError — retrait de 500.00 € refusé, disponible : 30.00 € +✗ Alice se vire 10 € : impossible de faire un virement vers le même compte + +=== Relevé du compte FR-0001 — Alice === +14/03/2026 10:32 dépôt +200.00 € solde : 200.00 € (Dépôt initial) +14/03/2026 10:32 retrait -50.00 € solde : 150.00 € (Courses) +14/03/2026 10:32 virement émis -120.00 € solde : 30.00 € (vers Bob Remboursement) +Solde actuel : 30.00 € + +=== Relevé du compte FR-0002 — Bob === +14/03/2026 10:32 retrait -80.00 € solde : -80.00 € (Loyer) +14/03/2026 10:32 virement reçu +120.00 € solde : 40.00 € (de Alice Remboursement) +Solde actuel : 40.00 € + +Totaux d'Alice : {'dépôt': 200.0, 'retrait': -50.0, 'virement émis': -120.0} +``` + +Points importants : + +- **Une seule méthode modifie `_solde`** : `_enregistrer`. Impossible d'oublier d'ajouter une ligne à l'historique, puisque les deux sont faits au même endroit. +- `virer` **réutilise** `retirer` et `deposer` (avec un paramètre `type_operation`), donc profite gratuitement de toutes leurs vérifications. Le retrait est fait **en premier** : s'il échoue, l'exception interrompt `virer` avant tout dépôt, et aucun argent n'est créé. +- Le solde initial passe par `deposer` : il apparaît dans l'historique, et le relevé est toujours cohérent. +- `{self.date:%d/%m/%Y %H:%M}` : une f-string accepte directement un format de date ([chapitre 09](09-modules-bibliotheque-standard.md)). `{montant:>+9.2f}` affiche toujours le signe (`+` ou `-`). +- `@staticmethod` devant `_verifier_montant` indique une méthode qui n'a pas besoin de `self` ; elle sera expliquée au [chapitre 11](11-poo-avancee-heritage-typage.md). Sans ce décorateur, on aurait simplement écrit `def _verifier_montant(self, montant):`. +- La démonstration range des `lambda` dans une liste pour tester chaque opération dans le même `try` : pratique pour essayer plein de cas sans répéter le code. + +
+ +--- + +## Récapitulatif + +- Une **classe** est un modèle (le moule) ; un **objet** ou **instance** est un exemplaire concret (le gâteau). On crée un objet en appelant la classe : `Chien("Rex")`. +- Les **attributs** sont les données d'un objet (`self.nom`), les **méthodes** ses actions (`def aboyer(self):`). +- `__init__` est le **constructeur** : il est appelé automatiquement à la création et initialise les attributs. +- **`self` est l'objet courant**. `rex.aboyer()` équivaut à `Chien.aboyer(rex)` : toute méthode reçoit `self` en premier paramètre, et on accède aux attributs avec `self.`. +- `__str__` (affichage pour l'utilisateur) et `__repr__` (affichage pour le développeur) doivent **renvoyer** une chaîne. +- Un **attribut de classe** est partagé par toutes les instances : réservé aux constantes et compteurs. Les listes et dictionnaires propres à chaque objet se créent **dans `__init__`**. +- Les objets sont manipulés **par référence** : `b = a` ne copie pas l'objet. `is` teste l'identité, `==` l'égalité (personnalisable avec `__eq__`). +- Une classe protège sa cohérence en **validant** dans ses méthodes et en **levant des exceptions**. Le tiret bas (`_solde`) signale un attribut interne. +- Les classes se combinent : un objet peut contenir d'autres objets (composition). +- Conventions : `CamelCase` pour les classes, `snake_case` pour les attributs et méthodes. + +### Checklist de validation + +- [ ] Je sais expliquer la différence entre une classe et un objet avec mes propres mots. +- [ ] Je sais écrire une classe avec un `__init__` et plusieurs attributs. +- [ ] Je sais expliquer ce qu'est `self` et pourquoi `objet.methode()` passe l'objet automatiquement. +- [ ] Je sais écrire des méthodes qui modifient l'objet et d'autres qui renvoient une valeur. +- [ ] Mes classes ont un `__repr__` (et un `__str__` si besoin) qui renvoie une chaîne. +- [ ] Je sais pourquoi `articles = []` au niveau de la classe est un bug. +- [ ] Je sais faire interagir deux objets (une méthode qui reçoit un autre objet). +- [ ] Mon compte bancaire refuse les opérations invalides et garde un historique cohérent. + +--- + +[← Chapitre précédent](09-modules-bibliotheque-standard.md) | [Sommaire](README.md) | [Chapitre suivant →](11-poo-avancee-heritage-typage.md) diff --git a/docs/presentations/Python/11-poo-avancee-heritage-typage.md b/docs/presentations/Python/11-poo-avancee-heritage-typage.md new file mode 100644 index 0000000..e10b4b3 --- /dev/null +++ b/docs/presentations/Python/11-poo-avancee-heritage-typage.md @@ -0,0 +1,2490 @@ +# Chapitre 11 — POO avancée : héritage, propriétés, dataclasses et typage + +> **Durée estimée** : 6 à 7 heures (cours + exercices + mini-projet) +> +> **Prérequis** : [Chapitre 10 — Classes et objets](10-poo-classes-objets.md) (classes, `__init__`, `self`, méthodes, `__str__` / `__repr__`). +> +> **Objectifs** — À la fin de ce chapitre, tu sauras : +> - annoter ton code avec des types (`int`, `list[int]`, `dict[str, float]`, `X | None`…) et le vérifier avec **mypy** ; +> - créer des classes qui **héritent** d'autres classes, redéfinir des méthodes et utiliser `super()` ; +> - tirer parti du **polymorphisme** et choisir entre **héritage** et **composition** ; +> - contrôler l'accès aux attributs avec `@property` ; +> - écrire des **méthodes de classe** (`@classmethod`) et des **méthodes statiques** (`@staticmethod`) ; +> - écrire des classes de données concises et robustes avec `@dataclass`. + +--- + +## Cours + +### 1. Les annotations de type + +#### Le problème + +Regarde cette fonction : + +```python +def calculer_total(articles, remise): + ... +``` + +Que contient `articles` ? Une liste de prix ? Un dictionnaire ? Des objets `Article` ? Et `remise`, c'est `0.1` ou `10` (pour 10 %) ? Impossible de savoir sans lire tout le code. Les **annotations de type** (*type hints*) servent à l'écrire noir sur blanc : + +```python +def calculer_total(prix: list[float], remise: float) -> float: + """Renvoie la somme des prix, avec une remise entre 0 et 1.""" + return sum(prix) * (1 - remise) + + +print(calculer_total([10.0, 20.0, 5.5], 0.1)) +``` + +``` +31.95 +``` + +On lit : « `prix` est une **liste de float**, `remise` est un **float**, et la fonction **renvoie** (`->`) un **float** ». + +> 💡 **Analogie** : les annotations, ce sont les étiquettes sur les prises électriques (« 230 V », « USB-C »). Elles n'empêchent pas physiquement de brancher n'importe quoi, mais elles te disent ce qui est prévu, et un outil peut vérifier que tu ne te trompes pas. + +#### Les annotations ne changent pas l'exécution + +C'est le point le plus important à comprendre : **Python ignore les annotations à l'exécution**. Elles sont destinées aux humains, à ton éditeur et aux outils de vérification. + +```python +def doubler(nombre: int) -> int: + return nombre * 2 + + +print(doubler(21)) +print(doubler("ha")) # pas d'erreur à l'exécution ! +``` + +``` +42 +haha +``` + +Python a accepté une chaîne alors qu'on annonçait un `int`. Pour détecter ce genre d'erreur, on utilise un **vérificateur de types** comme **mypy** (section 2) ou l'éditeur VS Code, qui souligne le problème avant même que tu lances le programme. + +#### La syntaxe de base + +```python +# Variables (rarement nécessaire : le type se devine souvent tout seul) +age: int = 30 +prix: float = 9.99 +nom: str = "Léa" +est_admin: bool = False + +# Paramètres et valeur de retour +def saluer(prenom: str, fois: int = 1) -> str: + return ("Bonjour " + prenom + " ! ") * fois + + +# Une fonction qui ne renvoie rien renvoie None +def afficher_titre(titre: str) -> None: + print(titre.upper()) + + +print(saluer("Léa", 2)) +afficher_titre("menu") +``` + +``` +Bonjour Léa ! Bonjour Léa ! +MENU +``` + +Note la position : `: type` après chaque paramètre, avant la valeur par défaut ; `-> type` avant les deux-points de la fonction. + +#### Les collections + +Pour les structures de données du [chapitre 04](04-structures-de-donnees.md), on précise **le type des éléments** entre crochets : + +| Annotation | Signification | Exemple de valeur | +|---|---|---| +| `list[int]` | liste d'entiers | `[1, 2, 3]` | +| `list[str]` | liste de chaînes | `["a", "b"]` | +| `dict[str, float]` | dictionnaire clé `str` → valeur `float` | `{"pomme": 1.2}` | +| `dict[str, list[int]]` | clé `str` → liste d'entiers | `{"Alice": [12, 15]}` | +| `set[str]` | ensemble de chaînes | `{"rouge", "vert"}` | +| `tuple[int, int]` | tuple de **exactement** 2 entiers | `(3, 4)` | +| `tuple[str, int, float]` | tuple de 3 éléments de types différents | `("Léa", 25, 1.68)` | +| `tuple[int, ...]` | tuple d'entiers de longueur quelconque | `(1, 2, 3, 4)` | + +```python +def moyennes(notes: dict[str, list[float]]) -> dict[str, float]: + """Calcule la moyenne de chaque élève.""" + return {eleve: sum(valeurs) / len(valeurs) for eleve, valeurs in notes.items()} + + +def min_max(valeurs: list[int]) -> tuple[int, int]: + return min(valeurs), max(valeurs) + + +print(moyennes({"Alice": [12, 16], "Bob": [9, 11, 13]})) +print(min_max([4, 8, 1, 9])) +``` + +``` +{'Alice': 14.0, 'Bob': 11.0} +(1, 9) +``` + +> ⚠️ Dans de vieux tutoriels, tu verras `from typing import List, Dict` puis `List[int]`. Depuis Python 3.9, on écrit directement `list[int]`, `dict[str, float]` en minuscules : c'est la forme moderne à utiliser. + +#### « Ceci ou cela » : `X | Y` et `X | None` + +Le symbole `|` (« ou ») indique qu'une valeur peut avoir **plusieurs types** : + +```python +def formater(valeur: int | float) -> str: + return f"{valeur:.2f}" + + +print(formater(3), formater(2.5)) +``` + +``` +3.00 2.50 +``` + +Le cas le plus fréquent est **`X | None`** : « une valeur de type X, ou bien `None` ». On l'utilise pour une fonction qui peut ne rien trouver, ou un paramètre optionnel : + +```python +def trouver_prix(stock: dict[str, float], produit: str) -> float | None: + """Renvoie le prix du produit, ou None s'il est inconnu.""" + return stock.get(produit) + + +def saluer(nom: str | None = None) -> str: + if nom is None: + return "Bonjour, inconnu !" + return f"Bonjour, {nom} !" + + +stock = {"pomme": 1.2, "kiwi": 0.5} +print(trouver_prix(stock, "kiwi"), trouver_prix(stock, "mangue")) +print(saluer(), saluer("Hugo")) +``` + +``` +0.5 None +Bonjour, inconnu ! Bonjour, Hugo ! +``` + +L'intérêt : en voyant `-> float | None`, tu sais immédiatement que tu **dois** gérer le cas `None` avant de faire un calcul avec le résultat. Et mypy te le rappellera si tu oublies. + +#### Annoter avec tes propres classes + +Tes classes sont aussi des types : + +```python +class Livre: + def __init__(self, titre: str, pages: int) -> None: + self.titre = titre + self.pages = pages + + +def total_pages(livres: list[Livre]) -> int: + return sum(livre.pages for livre in livres) + + +print(total_pages([Livre("Dune", 896), Livre("Le Petit Prince", 96)])) +``` + +``` +992 +``` + +`__init__` renvoie toujours `None` : on l'annote `-> None`. + +Quand une méthode fait référence à **sa propre classe** (pas encore complètement définie à ce moment-là), on met le nom entre guillemets : + +```python +class Point: + def __init__(self, x: float, y: float) -> None: + self.x = x + self.y = y + + def milieu(self, autre: "Point") -> "Point": + return Point((self.x + autre.x) / 2, (self.y + autre.y) / 2) +``` + +#### Les alias de type + +Si une annotation est longue et répétée, donne-lui un nom avec l'instruction `type` (Python 3.12+) : + +```python +type Carnet = dict[str, list[float]] + + +def ajouter_note(carnet: Carnet, eleve: str, note: float) -> None: + carnet.setdefault(eleve, []).append(note) + + +notes: Carnet = {} +ajouter_note(notes, "Alice", 14.5) +print(notes) +``` + +``` +{'Alice': [14.5]} +``` + +#### Faut-il tout annoter ? + +Non. Une règle raisonnable pour débuter : + +- **annote les signatures** des fonctions et méthodes (paramètres et retour) : c'est là que c'est le plus utile ; +- **n'annote pas** les variables locales dont le type est évident (`total = 0`, `noms = ["a", "b"]`) ; +- annote une variable quand elle démarre vide ou à `None` (`resultats: list[str] = []`, `gagnant: Joueur | None = None`). + +### 2. Vérifier les types avec mypy + +**mypy** est un outil qui lit ton code **sans l'exécuter** et vérifie que les types sont cohérents. On l'installe avec `pip` (voir [chapitre 12](12-outils-venv-pip-git.md)) : + +``` +pip install mypy +``` + +Prenons ce fichier `mon_script.py`, plein d'erreurs de type : + +```python +def moyenne(notes: list[float]) -> float: + return sum(notes) / len(notes) + + +def trouver(stock: dict[str, int], produit: str) -> int | None: + return stock.get(produit) + + +resultat = moyenne(["12", "15"]) +quantite: int = trouver({"pomme": 3}, "poire") +age: int = "vingt" +``` + +On lance mypy dans le terminal : + +``` +$ mypy mon_script.py +mon_script.py:9: error: List item 0 has incompatible type "str"; expected "float" [list-item] +mon_script.py:9: error: List item 1 has incompatible type "str"; expected "float" [list-item] +mon_script.py:10: error: Incompatible types in assignment (expression has type "int | None", variable has type "int") [assignment] +mon_script.py:11: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment] +Found 4 errors in 1 file (checked 1 source file) +``` + +Chaque ligne indique **le fichier, le numéro de ligne et le problème** : + +- ligne 9 : on passe des chaînes à une fonction qui attend des `float` ; +- ligne 10 : `trouver` peut renvoyer `None`, mais on range le résultat dans une variable annoncée `int` (mypy te rappelle de gérer le cas `None`) ; +- ligne 11 : `"vingt"` n'est pas un `int`. + +Une fois corrigé : + +``` +$ mypy mon_script.py +Success: no issues found in 1 source file +``` + +Quelques conseils : + +- Dans VS Code, l'extension Python (Pylance) fait déjà une partie de ce travail en **soulignant** les incohérences pendant que tu tapes. Tu peux régler son niveau d'exigence dans les paramètres (`Python › Analysis: Type Checking Mode` sur `basic` ou `standard`). +- `mypy --strict mon_script.py` est beaucoup plus exigeant (il réclame des annotations partout). C'est un bon objectif pour un projet abouti, pas forcément pour débuter. +- mypy ne vérifie que les fonctions **annotées** : une fonction sans aucune annotation est ignorée par défaut. + +### 3. L'héritage + +#### Le principe + +Imagine un jeu avec des guerriers, des mages et des voleurs. Ils ont tous un nom, des points de vie, une méthode pour subir des dégâts… et chacun a aussi ses spécificités. Recopier le code commun dans trois classes serait une très mauvaise idée (une correction devrait être faite trois fois). + +L'**héritage** permet de créer une classe **à partir d'une autre** : la nouvelle classe (l'**enfant**, ou sous-classe) **reçoit automatiquement** tous les attributs et méthodes de la classe existante (le **parent**, ou classe de base), et peut en ajouter ou en modifier. + +> 💡 **Analogie** : en biologie, un chien **est un** mammifère : il hérite de tout ce qu'ont les mammifères (respirer, allaiter…), et ajoute ses propres caractéristiques (aboyer). En POO, si tu peux dire « un X **est un** Y », X peut hériter de Y. + +```python +class Animal: + def __init__(self, nom: str) -> None: + self.nom = nom + + def manger(self) -> str: + return f"{self.nom} mange." + + def parler(self) -> str: + return f"{self.nom} fait un bruit." + + +class Chien(Animal): # Chien hérite d'Animal + def rapporter(self) -> str: + return f"{self.nom} rapporte la balle !" + + +rex = Chien("Rex") +print(rex.manger()) # méthode héritée d'Animal +print(rex.parler()) # méthode héritée d'Animal +print(rex.rapporter()) # méthode propre à Chien +``` + +``` +Rex mange. +Rex fait un bruit. +Rex rapporte la balle ! +``` + +La syntaxe est `class Enfant(Parent):`. `Chien` n'a pas de `__init__` : il utilise celui d'`Animal`, d'où l'attribut `nom`. + +#### Redéfinir une méthode + +Un enfant peut **redéfinir** (on dit aussi « surcharger », en anglais *override*) une méthode du parent, simplement en écrivant une méthode **du même nom** : + +```python +class Animal: + def __init__(self, nom: str) -> None: + self.nom = nom + + def parler(self) -> str: + return f"{self.nom} fait un bruit." + + +class Chien(Animal): + def parler(self) -> str: # remplace la version d'Animal + return f"{self.nom} fait : Wouf !" + + +class Chat(Animal): + def parler(self) -> str: + return f"{self.nom} fait : Miaou." + + +class Poisson(Animal): + pass # garde la version d'Animal + + +for animal in [Chien("Rex"), Chat("Félix"), Poisson("Nemo")]: + print(animal.parler()) +``` + +``` +Rex fait : Wouf ! +Félix fait : Miaou. +Nemo fait un bruit. +``` + +Quand on appelle `animal.parler()`, Python cherche la méthode **d'abord dans la classe de l'objet**, puis, s'il ne la trouve pas, **dans son parent**, puis dans le parent du parent, etc. + +#### `super()` : appeler la version du parent + +Souvent, on ne veut pas **remplacer** complètement la méthode du parent, mais la **compléter**. C'est le rôle de `super()`, qui donne accès aux méthodes de la classe parente. Le cas le plus courant est `__init__` : + +```python +class Animal: + def __init__(self, nom: str, age: int) -> None: + self.nom = nom + self.age = age + + def decrire(self) -> str: + return f"{self.nom}, {self.age} an(s)" + + +class Chien(Animal): + def __init__(self, nom: str, age: int, race: str) -> None: + super().__init__(nom, age) # le parent s'occupe de nom et age + self.race = race # l'enfant ajoute ce qui lui est propre + + def decrire(self) -> str: + return super().decrire() + f", {self.race}" # on complète le texte du parent + + +rex = Chien("Rex", 4, "berger allemand") +print(rex.decrire()) +print(vars(rex)) +``` + +``` +Rex, 4 an(s), berger allemand +{'nom': 'Rex', 'age': 4, 'race': 'berger allemand'} +``` + +> ⚠️ Si l'enfant définit son propre `__init__`, celui du parent **n'est plus appelé automatiquement**. Oublier `super().__init__(...)` est l'erreur d'héritage la plus courante (voir les pièges). + +#### `isinstance` et `issubclass` + +```python +class Animal: + pass + + +class Chien(Animal): + pass + + +class Labrador(Chien): + pass + + +medor = Labrador() +print(isinstance(medor, Labrador), isinstance(medor, Chien), isinstance(medor, Animal)) +print(isinstance(Animal(), Chien)) +print(issubclass(Labrador, Animal)) +print(Labrador.__mro__) +``` + +``` +True True True +False +True +(, , , ) +``` + +- `isinstance(objet, Classe)` : l'objet est-il de cette classe **ou d'une de ses sous-classes** ? Un labrador **est un** chien, et **est un** animal. +- `issubclass(A, B)` : la classe A hérite-t-elle (directement ou non) de B ? +- `__mro__` (*Method Resolution Order*) montre l'**ordre de recherche** des méthodes. Tout en haut, il y a toujours `object` : en Python, **toutes les classes héritent de `object`**, même quand on ne l'écrit pas. C'est de lui que viennent le `__repr__` par défaut (`<__main__.Chien object at 0x...>`) et bien d'autres choses. + +Tu avais déjà utilisé l'héritage sans le savoir : au [chapitre 08](08-erreurs-et-debogage.md), `class SoldeInsuffisantError(Exception): pass` crée une classe qui **hérite** de `Exception`. Elle reçoit tout le comportement d'une exception (message, traceback…) sans une ligne de code supplémentaire. Et `except LookupError` attrape les `KeyError` parce que `KeyError` **hérite** de `LookupError` ! + +> 💡 Python permet aussi l'**héritage multiple** (`class C(A, B):`). C'est un outil puissant mais délicat ; tu n'en auras pas besoin pour débuter. + +### 4. Le polymorphisme + +Le mot fait peur, l'idée est simple : **des objets de types différents peuvent répondre au même message (la même méthode), chacun à sa façon**. Le code qui les utilise n'a pas besoin de savoir à quel type précis il a affaire. + +```python +class Forme: + def aire(self) -> float: + raise NotImplementedError("chaque forme doit définir son aire") + + def decrire(self) -> str: + return f"{type(self).__name__} d'aire {self.aire():.2f}" + + +class Rectangle(Forme): + def __init__(self, largeur: float, hauteur: float) -> None: + self.largeur = largeur + self.hauteur = hauteur + + def aire(self) -> float: + return self.largeur * self.hauteur + + +class Cercle(Forme): + def __init__(self, rayon: float) -> None: + self.rayon = rayon + + def aire(self) -> float: + return 3.14159 * self.rayon**2 + + +class Triangle(Forme): + def __init__(self, base: float, hauteur: float) -> None: + self.base = base + self.hauteur = hauteur + + def aire(self) -> float: + return self.base * self.hauteur / 2 + + +formes: list[Forme] = [Rectangle(4, 3), Cercle(1), Triangle(6, 2)] +for forme in formes: + print(forme.decrire()) +print(f"Aire totale : {sum(forme.aire() for forme in formes):.2f}") +``` + +``` +Rectangle d'aire 12.00 +Cercle d'aire 3.14 +Triangle d'aire 6.00 +Aire totale : 21.14 +``` + +Regarde la boucle : elle appelle `forme.aire()` sans jamais tester `if isinstance(forme, Cercle)...`. Chaque objet sait calculer **sa** propre aire. Si demain tu ajoutes une classe `Hexagone`, la boucle fonctionnera **sans aucune modification**. C'est toute la puissance du polymorphisme. + +Remarque aussi `decrire()`, écrite **une seule fois** dans `Forme`, qui appelle `self.aire()` : selon l'objet, c'est la version du `Rectangle`, du `Cercle` ou du `Triangle` qui est exécutée. + +#### Le « typage canard » + +En Python, le polymorphisme ne nécessite même pas l'héritage. Si un objet possède la bonne méthode, ça marche : + +> « Si ça marche comme un canard et que ça cancane comme un canard, alors c'est un canard. » + +```python +class Canard: + def cancaner(self) -> str: + return "Coin coin !" + + +class Robot: + def cancaner(self) -> str: + return "BIP. COIN. BIP." + + +for chose in [Canard(), Robot()]: + print(chose.cancaner()) +``` + +``` +Coin coin ! +BIP. COIN. BIP. +``` + +C'est pour cela que `len()` fonctionne sur les listes, les chaînes, les dictionnaires… et sur ta `Playlist` du chapitre 10 : il suffit d'avoir une méthode `__len__`. + +### 5. Les classes abstraites + +Dans l'exemple des formes, `Forme().aire()` lèverait une `NotImplementedError`. Mais rien n'empêche de créer un objet `Forme()` qui n'a aucun sens, ou d'oublier de définir `aire` dans une sous-classe. Le module `abc` (*Abstract Base Classes*) permet de rendre cela **impossible** : + +```python +from abc import ABC, abstractmethod + + +class Forme(ABC): + @abstractmethod + def aire(self) -> float: + """Chaque forme DOIT définir cette méthode.""" + + def decrire(self) -> str: + return f"{type(self).__name__} d'aire {self.aire():.2f}" + + +class Carre(Forme): + def __init__(self, cote: float) -> None: + self.cote = cote + + def aire(self) -> float: + return self.cote**2 + + +print(Carre(3).decrire()) +Forme() +``` + +``` +Carre d'aire 9.00 +Traceback (most recent call last): + File "/home/toi/cours/mon_script.py", line 22, in + Forme() + ~~~~~^^ +TypeError: Can't instantiate abstract class Forme without an implementation for abstract method 'aire' +``` + +- Une classe qui hérite de `ABC` et possède au moins une méthode marquée `@abstractmethod` est **abstraite** : on ne peut pas créer d'objet directement à partir d'elle. +- Une sous-classe qui **oublie** de définir `aire` est elle aussi abstraite : l'erreur apparaît dès la création de l'objet, et non au moment où on appelle `aire()` bien plus tard. +- `@abstractmethod` est un **décorateur** : une ligne commençant par `@` placée au-dessus d'une fonction pour modifier son comportement. Tu vas en voir d'autres : `@property`, `@classmethod`, `@staticmethod`, `@dataclass`. + +### 6. Composition ou héritage ? + +L'héritage est tentant, mais il est souvent **surutilisé** par les débutants. Il existe une alternative : la **composition**, qui consiste à **mettre un objet à l'intérieur d'un autre**, comme la `Commande` qui contenait des `Article` au [chapitre 10](10-poo-classes-objets.md). + +Le test le plus simple : + +| Relation | Phrase | Technique | Exemple | +|---|---|---|---| +| **est un** | « un chien **est un** animal » | héritage | `class Chien(Animal)` | +| **a un** | « une voiture **a un** moteur » | composition | `self.moteur = Moteur()` | + +Une voiture n'**est** pas un moteur ; elle **en possède** un. Faire hériter `Voiture` de `Moteur` serait absurde (une voiture aurait alors une méthode `regler_injection` à son niveau…). + +```python +class Moteur: + def __init__(self, puissance: int) -> None: + self.puissance = puissance + self.allume = False + + def demarrer(self) -> str: + self.allume = True + return f"Moteur de {self.puissance} ch démarré" + + +class Roue: + def __init__(self, diametre: int) -> None: + self.diametre = diametre + + +class Voiture: + def __init__(self, marque: str, puissance: int) -> None: + self.marque = marque + self.moteur = Moteur(puissance) # une voiture A UN moteur + self.roues = [Roue(17) for _ in range(4)] # et A quatre roues + + def demarrer(self) -> str: + return f"{self.marque} : {self.moteur.demarrer()}" # on DÉLÈGUE au moteur + + +voiture = Voiture("Renault", 130) +print(voiture.demarrer()) +print(voiture.moteur.allume, len(voiture.roues)) +``` + +``` +Renault : Moteur de 130 ch démarré +True 4 +``` + +La voiture **délègue** le travail à son moteur. Avantages de la composition : + +- chaque classe reste **petite et simple** ; +- on peut **changer une pièce** sans toucher au reste (remplacer `Moteur` par `MoteurElectrique` qui a aussi une méthode `demarrer`) ; +- on évite les hiérarchies d'héritage profondes et fragiles. + +> 💡 Un conseil célèbre de la conception logicielle : **« préfère la composition à l'héritage »**. Utilise l'héritage quand la relation « est un » est évidente **et** que l'enfant peut remplacer le parent partout sans surprise. Dans le doute, compose. + +Un exemple classique d'héritage **mal choisi** : + +```python +class Pile(list): # ❌ une pile « est une » liste ? + def empiler(self, element: int) -> None: + self.append(element) + + def depiler(self) -> int: + return self.pop() + + +pile = Pile() +pile.empiler(1) +pile.empiler(2) +pile.insert(0, 99) # oups : une pile ne devrait pas permettre d'insérer au fond ! +print(pile) +``` + +``` +[99, 1, 2] +``` + +En héritant de `list`, la `Pile` a hérité **de toutes** les méthodes des listes, y compris celles qui cassent sa logique. Avec la composition, on n'expose que ce qu'on veut : + +```python +class Pile: + def __init__(self) -> None: + self._elements: list[int] = [] # la pile A une liste + + def empiler(self, element: int) -> None: + self._elements.append(element) + + def depiler(self) -> int: + return self._elements.pop() + + def __len__(self) -> int: + return len(self._elements) + + +pile = Pile() +pile.empiler(1) +pile.empiler(2) +print(pile.depiler(), len(pile)) +``` + +``` +2 1 +``` + +### 7. Les propriétés : `@property` + +#### Le problème + +Au [chapitre 10](10-poo-classes-objets.md), pour protéger la température d'un thermostat, on avait écrit une méthode `regler(...)` et une méthode `temperature()`. Ça marche, mais c'est lourd : on aimerait écrire `salon.temperature = 21` et `print(salon.temperature)`, **comme un attribut normal**, tout en gardant la validation. + +`@property` permet exactement cela : une méthode **déguisée en attribut**. + +#### Une propriété calculée (lecture seule) + +```python +class Rectangle: + def __init__(self, largeur: float, hauteur: float) -> None: + self.largeur = largeur + self.hauteur = hauteur + + @property + def aire(self) -> float: + return self.largeur * self.hauteur + + +r = Rectangle(4, 3) +print(r.aire) # pas de parenthèses : on l'utilise comme un attribut +r.largeur = 10 +print(r.aire) # toujours à jour, car recalculée à chaque accès +``` + +``` +12 +30 +``` + +Si on avait stocké l'aire dans un attribut `self.aire = largeur * hauteur` dans `__init__`, elle serait devenue **fausse** après la modification de la largeur. La propriété est recalculée à chaque lecture : elle ne peut pas se désynchroniser. + +#### Une propriété modifiable avec validation (*setter*) + +```python +class Thermostat: + def __init__(self, temperature: float = 19) -> None: + self.temperature = temperature # passe déjà par le setter ! + + @property + def temperature(self) -> float: # le « getter » : lecture + return self._temperature + + @temperature.setter + def temperature(self, valeur: float) -> None: # le « setter » : écriture + if not 5 <= valeur <= 30: + raise ValueError(f"température hors limites : {valeur}") + self._temperature = valeur + + @property + def fahrenheit(self) -> float: + return self._temperature * 9 / 5 + 32 + + +salon = Thermostat() +salon.temperature = 21 # appelle le setter +print(salon.temperature) # appelle le getter +print(salon.fahrenheit) +try: + salon.temperature = 45 +except ValueError as erreur: + print("Refusé :", erreur) +print(salon.temperature) +``` + +``` +21 +69.8 +Refusé : température hors limites : 45 +21 +``` + +Comment ça fonctionne : + +1. `@property` au-dessus de `def temperature(self)` crée une propriété : lire `salon.temperature` appelle cette méthode. +2. `@temperature.setter` au-dessus d'une **deuxième** méthode **du même nom** définit ce qui se passe quand on **écrit** `salon.temperature = ...`. +3. La vraie valeur est stockée dans un attribut **interne** avec un tiret bas, `_temperature`. Il **doit** avoir un nom différent de la propriété (sinon : boucle infinie, voir les pièges). +4. Dans `__init__`, `self.temperature = temperature` passe déjà par le setter : la validation s'applique aussi à la création. + +Une propriété **sans setter** est en **lecture seule** : `salon.fahrenheit = 50` provoque `AttributeError: property 'fahrenheit' of 'Thermostat' object has no setter`. + +> 💡 En Python, on commence **toujours** par des attributs simples (`self.temperature = ...`). Le jour où on a besoin de validation, on transforme l'attribut en propriété, et **le code qui utilise la classe ne change pas** : il continue d'écrire `objet.temperature`. Inutile donc d'écrire des méthodes `get_temperature()` / `set_temperature()` comme dans d'autres langages. + +### 8. Méthodes de classe et méthodes statiques + +Jusqu'ici, toutes nos méthodes travaillaient sur **un objet** (`self`). Il existe deux autres sortes de méthodes. + +#### `@classmethod` : une méthode qui reçoit la classe + +Une **méthode de classe** reçoit **la classe elle-même** en premier paramètre, nommé par convention `cls`, au lieu de l'objet. Son usage principal : fournir **d'autres façons de créer des objets** (on parle de « constructeurs alternatifs »). + +```python +class Date: + def __init__(self, jour: int, mois: int, annee: int) -> None: + self.jour = jour + self.mois = mois + self.annee = annee + + @classmethod + def depuis_texte(cls, texte: str) -> "Date": + """Crée une Date à partir d'un texte 'JJ/MM/AAAA'.""" + jour, mois, annee = (int(morceau) for morceau in texte.split("/")) + return cls(jour, mois, annee) # cls(...) équivaut ici à Date(...) + + @classmethod + def depuis_iso(cls, texte: str) -> "Date": + """Crée une Date à partir d'un texte 'AAAA-MM-JJ'.""" + annee, mois, jour = (int(morceau) for morceau in texte.split("-")) + return cls(jour, mois, annee) + + def __repr__(self) -> str: + return f"Date({self.jour}, {self.mois}, {self.annee})" + + +print(Date(14, 7, 2025)) +print(Date.depuis_texte("03/02/2026")) # appelée sur la CLASSE, pas sur un objet +print(Date.depuis_iso("2026-12-25")) +``` + +``` +Date(14, 7, 2025) +Date(3, 2, 2026) +Date(25, 12, 2026) +``` + +Tu en as déjà utilisé : `date.fromisoformat(...)` et `datetime.strptime(...)` au [chapitre 09](09-modules-bibliotheque-standard.md) sont des méthodes de classe ! Pourquoi `cls(...)` plutôt que `Date(...)` ? Parce que si une sous-classe (`DateAnniversaire(Date)`) appelle `depuis_texte`, `cls` sera la sous-classe, et c'est bien un objet `DateAnniversaire` qui sera créé. + +#### `@staticmethod` : une simple fonction rangée dans la classe + +Une **méthode statique** ne reçoit **ni `self` ni `cls`**. C'est une fonction ordinaire, placée dans la classe parce qu'elle lui est **logiquement liée** : + +```python +class Date: + def __init__(self, jour: int, mois: int, annee: int) -> None: + if not Date.est_valide(jour, mois, annee): + raise ValueError(f"date invalide : {jour}/{mois}/{annee}") + self.jour = jour + self.mois = mois + self.annee = annee + + @staticmethod + def est_bissextile(annee: int) -> bool: + return annee % 4 == 0 and (annee % 100 != 0 or annee % 400 == 0) + + @staticmethod + def est_valide(jour: int, mois: int, annee: int) -> bool: + jours_par_mois = [31, 29 if Date.est_bissextile(annee) else 28, 31, 30, + 31, 30, 31, 31, 30, 31, 30, 31] + return 1 <= mois <= 12 and 1 <= jour <= jours_par_mois[mois - 1] + + +print(Date.est_bissextile(2024), Date.est_bissextile(1900), Date.est_bissextile(2000)) +print(Date.est_valide(29, 2, 2025)) +try: + Date(31, 4, 2026) +except ValueError as erreur: + print(erreur) +``` + +``` +True False True +False +date invalide : 31/4/2026 +``` + +#### Résumé + +| Type de méthode | Décorateur | Premier paramètre | Accès à | Usage typique | +|---|---|---|---|---| +| Méthode d'instance | (aucun) | `self` (l'objet) | l'objet et sa classe | 95 % des cas | +| Méthode de classe | `@classmethod` | `cls` (la classe) | la classe | constructeurs alternatifs | +| Méthode statique | `@staticmethod` | (aucun) | rien de spécial | fonction utilitaire liée à la classe | + +### 9. Les dataclasses + +#### Le problème : beaucoup de code répétitif + +Beaucoup de classes servent surtout à **regrouper des données**. Pour chacune, on réécrit le même code fastidieux : + +```python +class Produit: + def __init__(self, nom: str, prix: float, stock: int = 0) -> None: + self.nom = nom + self.prix = prix + self.stock = stock + + def __repr__(self) -> str: + return f"Produit(nom={self.nom!r}, prix={self.prix!r}, stock={self.stock!r})" + + def __eq__(self, autre: object) -> bool: + if not isinstance(autre, Produit): + return NotImplemented + return (self.nom, self.prix, self.stock) == (autre.nom, autre.prix, autre.stock) +``` + +Quinze lignes pour trois attributs ! Le module `dataclasses` génère tout cela **automatiquement**. + +#### `@dataclass` + +```python +from dataclasses import dataclass + + +@dataclass +class Produit: + nom: str + prix: float + stock: int = 0 + + +stylo = Produit("Stylo", 1.5, 100) +gomme = Produit("Gomme", 0.8) + +print(stylo) # __repr__ automatique +print(gomme.stock) # valeur par défaut +print(stylo == Produit("Stylo", 1.5, 100)) # __eq__ automatique +stylo.stock -= 1 # attributs modifiables normalement +print(stylo.stock) +``` + +``` +Produit(nom='Stylo', prix=1.5, stock=100) +0 +True +99 +``` + +On déclare simplement les attributs **avec leur type** (les annotations de la section 1 sont ici **obligatoires**), et le décorateur `@dataclass` écrit pour nous `__init__`, `__repr__` et `__eq__`. Les deux versions de `Produit` sont équivalentes, mais la seconde fait 5 lignes. + +Une dataclass est une classe normale : tu peux lui ajouter des méthodes et des propriétés. + +```python +from dataclasses import dataclass + + +@dataclass +class LigneCommande: + produit: str + prix_unitaire: float + quantite: int = 1 + + @property + def total(self) -> float: + return self.prix_unitaire * self.quantite + + def __str__(self) -> str: + return f"{self.quantite} x {self.produit} = {self.total:.2f} €" + + +ligne = LigneCommande("Cahier", 2.5, 4) +print(ligne) +print(repr(ligne)) +``` + +``` +4 x Cahier = 10.00 € +LigneCommande(produit='Cahier', prix_unitaire=2.5, quantite=4) +``` + +#### Les valeurs par défaut mutables : `field(default_factory=...)` + +Tu te souviens du piège de la liste partagée ([chapitre 10](10-poo-classes-objets.md)) ? Les dataclasses te protègent : elles **refusent** une liste comme valeur par défaut. On utilise `field(default_factory=list)`, qui crée une **nouvelle** liste pour chaque objet : + +```python +from dataclasses import dataclass, field + + +@dataclass +class Equipe: + nom: str + membres: list[str] = field(default_factory=list) + + +rouges = Equipe("Rouges") +bleus = Equipe("Bleus") +rouges.membres.append("Léa") +print(rouges) +print(bleus) +``` + +``` +Equipe(nom='Rouges', membres=['Léa']) +Equipe(nom='Bleus', membres=[]) +``` + +#### Valider avec `__post_init__` + +`__init__` est généré automatiquement… alors où mettre la validation ? Dans `__post_init__`, une méthode appelée **juste après** l'`__init__` automatique : + +```python +from dataclasses import dataclass + + +@dataclass +class Produit: + nom: str + prix: float + stock: int = 0 + + def __post_init__(self) -> None: + if self.prix < 0: + raise ValueError(f"prix négatif pour {self.nom} : {self.prix}") + self.nom = self.nom.strip().capitalize() + + +print(Produit(" clavier ", 49.9)) +try: + Produit("souris", -5) +except ValueError as erreur: + print("Erreur :", erreur) +``` + +``` +Produit(nom='Clavier', prix=49.9, stock=0) +Erreur : prix négatif pour souris : -5 +``` + +#### Options utiles : `order=True` et `frozen=True` + +```python +from dataclasses import asdict, dataclass + + +@dataclass(order=True) +class Version: + majeure: int + mineure: int + correctif: int = 0 + + +versions = [Version(3, 12, 1), Version(3, 9), Version(3, 12), Version(2, 7, 18)] +print(sorted(versions)) +print(max(versions)) +print(Version(3, 12) < Version(3, 13)) + + +@dataclass(frozen=True) +class Point: + x: float + y: float + + +origine = Point(0, 0) +print(asdict(origine)) +try: + origine.x = 5 +except AttributeError as erreur: + print(type(erreur).__name__, ":", erreur) +``` + +``` +[Version(majeure=2, mineure=7, correctif=18), Version(majeure=3, mineure=9, correctif=0), Version(majeure=3, mineure=12, correctif=0), Version(majeure=3, mineure=12, correctif=1)] +Version(majeure=3, mineure=12, correctif=1) +True +{'x': 0, 'y': 0} +FrozenInstanceError : cannot assign to field 'x' +``` + +- **`order=True`** génère `<`, `<=`, `>`, `>=` : les objets se comparent attribut par attribut, **dans l'ordre de déclaration** (comme des tuples). `sorted`, `min` et `max` fonctionnent directement. +- **`frozen=True`** rend les objets **immuables** : toute modification lève une erreur (`FrozenInstanceError`, une sous-classe d'`AttributeError`), que mypy repère même avant l'exécution (`Property "x" defined in "Point" is read-only`). Utile pour des valeurs qui ne doivent jamais changer (coordonnées, configuration…). Bonus : un objet gelé peut servir de clé de dictionnaire ou d'élément d'ensemble. +- **`asdict(objet)`** convertit une dataclass en dictionnaire : parfait pour la sauvegarde en JSON ([chapitre 07](07-fichiers-csv-json.md)). + +#### Héritage de dataclasses + +Les dataclasses peuvent hériter les unes des autres ; l'enfant ajoute ses champs **après** ceux du parent : + +```python +from dataclasses import dataclass + + +@dataclass +class Media: + titre: str + annee: int + + +@dataclass +class Livre(Media): + auteur: str + pages: int + + +@dataclass +class Film(Media): + realisateur: str + duree: int + + +print(Livre("Dune", 1965, "Frank Herbert", 896)) +print(Film("Alien", 1979, "Ridley Scott", 117)) +``` + +``` +Livre(titre='Dune', annee=1965, auteur='Frank Herbert', pages=896) +Film(titre='Alien', annee=1979, realisateur='Ridley Scott', duree=117) +``` + +#### Dataclass ou classe classique ? + +| Utilise une **dataclass** quand… | Utilise une **classe classique** quand… | +|---|---| +| l'objet sert surtout à **porter des données** | l'objet a surtout du **comportement** et un état interne complexe | +| tu veux `__repr__` et `__eq__` gratuitement | l'`__init__` fait beaucoup plus que ranger les paramètres | +| les attributs sont publics et simples | tu veux contrôler finement chaque attribut | + +Dans le doute, commence par une dataclass : c'est plus court, et tu peux toujours y ajouter des méthodes. + +### 10. Tout assembler + +Voici un petit exemple qui combine toutes les notions du chapitre : annotations, classe abstraite, héritage, `super()`, propriété, méthode de classe, dataclass et composition. + +```python +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + + +@dataclass +class Adresse: + rue: str + ville: str + + def __str__(self) -> str: + return f"{self.rue}, {self.ville}" + + +class Employe(ABC): + def __init__(self, nom: str, adresse: Adresse) -> None: + self.nom = nom + self.adresse = adresse # composition : un employé A une adresse + + @property + @abstractmethod + def salaire_mensuel(self) -> float: ... + + @classmethod + def depuis_ligne(cls, ligne: str) -> "Employe": + """Crée l'employé adapté à partir d'une ligne 'type;nom;rue;ville;montant'.""" + type_employe, nom, rue, ville, montant = ligne.split(";") + adresse = Adresse(rue, ville) + if type_employe == "salarie": + return Salarie(nom, adresse, float(montant)) + return Freelance(nom, adresse, float(montant)) + + def __str__(self) -> str: + return f"{self.nom:<6} ({type(self).__name__:<9}) {self.salaire_mensuel:>8.2f} € — {self.adresse}" + + +class Salarie(Employe): + def __init__(self, nom: str, adresse: Adresse, salaire_annuel: float) -> None: + super().__init__(nom, adresse) + self.salaire_annuel = salaire_annuel + + @property + def salaire_mensuel(self) -> float: + return self.salaire_annuel / 12 + + +class Freelance(Employe): + def __init__(self, nom: str, adresse: Adresse, taux_journalier: float) -> None: + super().__init__(nom, adresse) + self.taux_journalier = taux_journalier + self.jours_factures = 0 + + @property + def salaire_mensuel(self) -> float: + return self.taux_journalier * self.jours_factures + + +@dataclass +class Entreprise: + nom: str + employes: list[Employe] = field(default_factory=list) + + @property + def masse_salariale(self) -> float: + return sum(employe.salaire_mensuel for employe in self.employes) + + +lignes = [ + "salarie;Alice;3 rue des Lilas;Lyon;42000", + "freelance;Bruno;8 quai Est;Nantes;450", + "salarie;Chloé;1 place Haute;Lille;36000", +] +societe = Entreprise("Python & Co") +for ligne in lignes: + societe.employes.append(Employe.depuis_ligne(ligne)) + +for employe in societe.employes: + if isinstance(employe, Freelance): + employe.jours_factures = 12 + +for employe in societe.employes: + print(employe) +print(f"Masse salariale de {societe.nom} : {societe.masse_salariale:.2f} €") +``` + +``` +Alice (Salarie ) 3500.00 € — 3 rue des Lilas, Lyon +Bruno (Freelance) 5400.00 € — 8 quai Est, Nantes +Chloé (Salarie ) 3000.00 € — 1 place Haute, Lille +Masse salariale de Python & Co : 11900.00 € +``` + +Remarque `@property` **et** `@abstractmethod` empilés : chaque sous-classe doit fournir une propriété `salaire_mensuel`. Et `masse_salariale` profite du polymorphisme : elle additionne des salaires calculés de façons totalement différentes sans jamais tester le type. + +--- + +## Pièges fréquents + +### Piège 1 — Oublier d'appeler `super().__init__()` + +```python +class Animal: + def __init__(self, nom: str) -> None: + self.nom = nom + + +class Chien(Animal): + def __init__(self, nom: str, race: str) -> None: + self.race = race # ❌ et le nom ? + + +print(Chien("Rex", "labrador").nom) +``` + +``` +AttributeError: 'Chien' object has no attribute 'nom' +``` + +En définissant son propre `__init__`, `Chien` a **remplacé** celui d'`Animal`, qui n'est donc jamais exécuté. ✅ Ajoute `super().__init__(nom)` au début du `__init__` de l'enfant. + +### Piège 2 — La propriété qui s'appelle elle-même + +```python +class Temperature: + def __init__(self, celsius: float) -> None: + self.celsius = celsius + + @property + def celsius(self) -> float: + return self.celsius # ❌ relit la propriété… qui relit la propriété… + + @celsius.setter + def celsius(self, valeur: float) -> None: + self.celsius = valeur # ❌ rappelle le setter… qui rappelle le setter… + + +Temperature(20) +``` + +``` +Traceback (most recent call last): + File "/home/toi/cours/mon_script.py", line 14, in + Temperature(20) + ~~~~~~~~~~~^^^^ + File "/home/toi/cours/mon_script.py", line 3, in __init__ + self.celsius = celsius + ^^^^^^^^^^^^ + File "/home/toi/cours/mon_script.py", line 11, in celsius + self.celsius = valeur # ❌ rappelle le setter… qui rappelle le setter… + ^^^^^^^^^^^^ + File "/home/toi/cours/mon_script.py", line 11, in celsius + self.celsius = valeur # ❌ rappelle le setter… qui rappelle le setter… + ^^^^^^^^^^^^ + File "/home/toi/cours/mon_script.py", line 11, in celsius + self.celsius = valeur # ❌ rappelle le setter… qui rappelle le setter… + ^^^^^^^^^^^^ + [Previous line repeated 995 more times] +RecursionError: maximum recursion depth exceeded +``` + +Dans le setter, `self.celsius = ...` déclenche… le setter, à l'infini. ✅ Stocke la valeur dans un attribut **au nom différent**, avec un tiret bas : `self._celsius = valeur` et `return self._celsius`. + +### Piège 3 — Appeler une propriété avec des parenthèses + +```python +class Cercle: + def __init__(self, rayon: float) -> None: + self.rayon = rayon + + @property + def aire(self) -> float: + return 3.14159 * self.rayon**2 + + +c = Cercle(2) +print(c.aire()) +``` + +``` +TypeError: 'float' object is not callable +``` + +`c.aire` vaut **déjà** le résultat (`12.56636`) ; les parenthèses essaient ensuite d'« appeler » ce nombre. ✅ `print(c.aire)`. + +### Piège 4 — Une valeur par défaut mutable dans une dataclass + +```python +from dataclasses import dataclass + + +@dataclass +class Equipe: + nom: str + membres: list[str] = [] +``` + +``` +ValueError: mutable default for field membres is not allowed: use default_factory +``` + +Le message donne la solution. ✅ `membres: list[str] = field(default_factory=list)` (avec `from dataclasses import dataclass, field`). + +### Piège 5 — Un champ sans valeur par défaut après un champ qui en a une + +```python +from dataclasses import dataclass + + +@dataclass +class Produit: + nom: str + prix: float = 0.0 + stock: int +``` + +``` +TypeError: non-default argument 'stock' follows default argument 'prix' +``` + +C'est la même règle que pour les paramètres de fonction ([chapitre 05](05-fonctions.md)) : les paramètres **avec** valeur par défaut viennent **après** ceux qui n'en ont pas. ✅ Réordonne les champs (`nom`, `stock`, puis `prix = 0.0`) ou donne une valeur par défaut à `stock`. + +### Piège 6 — Croire que les annotations sont vérifiées à l'exécution + +```python +def ajouter_tva(prix: float) -> float: + return prix * 1.2 + + +print(ajouter_tva("10")) +``` + +``` +TypeError: can't multiply sequence by non-int of type 'float' +``` + +Python n'a **pas** refusé la chaîne à l'entrée de la fonction : il a essayé, et l'erreur est arrivée **plus loin**, avec un message sans rapport avec l'annotation. Avec un entier (`ajouter_tva(10)`), ça fonctionne d'ailleurs très bien : un `int` est accepté partout où un `float` est attendu. ✅ Lance **mypy** (ou regarde les soulignements de VS Code) pour détecter l'erreur avant l'exécution, et **valide** les données venant de l'extérieur (saisie, fichier) avec de vraies vérifications (`if` + `raise`). + +### Piège 7 — Hériter là où il faudrait composer + +```python +class Moteur: + def demarrer(self) -> str: + return "Vroum" + + +class Voiture(Moteur): # ❌ une voiture n'EST PAS un moteur + pass +``` + +Ça fonctionne techniquement, mais le modèle est faux : une `Voiture` serait acceptée partout où on attend un `Moteur`, et chaque méthode ajoutée à `Moteur` apparaîtrait sur la voiture. ✅ Pose-toi la question « **est un** ou **a un** ? » : `self.moteur = Moteur()` dans `Voiture.__init__`. + +### Piège 8 — Oublier d'implémenter une méthode abstraite + +```python +from abc import ABC, abstractmethod + + +class Forme(ABC): + @abstractmethod + def aire(self) -> float: ... + + +class Carre(Forme): + def __init__(self, cote: float) -> None: + self.cote = cote + + def surface(self) -> float: # ❌ mauvais nom : ce n'est pas « aire » + return self.cote**2 + + +Carre(2) +``` + +``` +TypeError: Can't instantiate abstract class Carre without an implementation for abstract method 'aire' +``` + +C'est justement l'intérêt des classes abstraites : l'oubli est détecté immédiatement. ✅ Renomme la méthode `aire`. + +--- + +## Exercices + +### Exercice 1 ⭐ — Annoter et vérifier + +Ajoute des annotations de type complètes à ces fonctions, puis vérifie-les avec `mypy`. Corrige ensuite l'**appel** erroné que mypy détecte. + +```python +def initiales(prenom, nom): + return prenom[0].upper() + "." + nom[0].upper() + "." + + +def compter_voyelles(mots): + resultat = {} + for mot in mots: + resultat[mot] = sum(1 for lettre in mot.lower() if lettre in "aeiouy") + return resultat + + +def chercher_age(annuaire, nom): + return annuaire.get(nom) + + +print(initiales("ada", "lovelace")) +print(compter_voyelles(["python", "classe"])) +age = chercher_age({"Alice": 30}, "Bob") +print(age + 1) +``` + +### Exercice 2 ⭐ — Une petite famille de véhicules + +Écris une classe `Vehicule` (marque, vitesse maximale en km/h) avec une méthode `decrire()` et une méthode `temps_trajet(distance_km)` qui renvoie le temps en heures à vitesse maximale. Crée deux sous-classes : + +- `Velo` : pas de paramètre de vitesse (toujours 25 km/h), et un attribut `electrique: bool` ; +- `Voiture` : un attribut supplémentaire `places`, et `decrire()` qui **complète** celle du parent avec `super()`. + +```python +vehicules = [Velo("Decathlon", electrique=True), Voiture("Renault", 180, places=5)] +for v in vehicules: + print(v.decrire(), f"— 100 km en {v.temps_trajet(100):.1f} h") +``` + +``` +Decathlon (max 25 km/h), électrique — 100 km en 4.0 h +Renault (max 180 km/h), 5 places — 100 km en 0.6 h +``` + +### Exercice 3 ⭐ — Propriétés de température + +Écris une classe `Temperature` qui stocke une température en degrés Celsius (attribut interne `_celsius`) et propose : + +- une propriété `celsius` (lecture + écriture), qui refuse les valeurs inférieures au zéro absolu (−273,15 °C) avec une `ValueError` ; +- une propriété `fahrenheit` (lecture + écriture) qui convertit dans les deux sens ; +- une propriété en lecture seule `etat` qui renvoie `"glace"`, `"liquide"` ou `"vapeur"` (pour l'eau, à pression normale). + +```python +t = Temperature(25) +print(t.celsius, t.fahrenheit, t.etat) # 25 77.0 liquide +t.fahrenheit = 212 +print(t.celsius, t.etat) # 100.0 vapeur +t.celsius = -300 # ValueError +``` + +### Exercice 4 ⭐⭐ — Dataclass de bibliothèque musicale + +Écris une dataclass `Morceau` (titre, artiste, durée en secondes, note de 0 à 5, avec 0 par défaut), avec : + +- une validation dans `__post_init__` (durée positive, note entre 0 et 5) ; +- une propriété `duree_texte` qui renvoie `"3:05"` pour 185 secondes. + +Puis une dataclass `Playlist` (nom, liste de morceaux vide par défaut) avec : + +- `ajouter(morceau)` ; +- une propriété `duree_totale` (en texte, même format, `"mm:ss"`) ; +- `meilleurs(n)` qui renvoie les `n` morceaux les mieux notés. + +```python +playlist = Playlist("Road trip") +playlist.ajouter(Morceau("Bohemian Rhapsody", "Queen", 354, 5)) +playlist.ajouter(Morceau("Get Lucky", "Daft Punk", 248, 4)) +playlist.ajouter(Morceau("Take On Me", "a-ha", 225, 3)) +print(playlist.duree_totale) # 13:47 +for morceau in playlist.meilleurs(2): + print(morceau.titre, morceau.duree_texte) +``` + +``` +13:47 +Bohemian Rhapsody 5:54 +Get Lucky 4:08 +``` + +### Exercice 5 ⭐⭐ — Méthodes de classe et statiques + +Écris une classe `Duree` qui stocke un nombre total de secondes, avec : + +- `@classmethod depuis_texte(cls, texte)` qui accepte `"1h30"`, `"45min"`, `"2h"`, `"1h05min"` ou `"90s"` ; +- `@classmethod depuis_minutes(cls, minutes)` ; +- `@staticmethod formater(secondes)` qui renvoie `"1h30min00s"` ; +- `__str__` qui utilise `formater`, et `__add__` pour additionner deux durées avec `+` ; +- `__lt__` pour pouvoir les trier (ou utilise `@dataclass(order=True)`). + +```python +trajets = [Duree.depuis_texte("1h30"), Duree.depuis_texte("45min"), Duree.depuis_minutes(20)] +print([str(d) for d in sorted(trajets)]) +print(trajets[0] + trajets[1]) +``` + +``` +['0h20min00s', '0h45min00s', '1h30min00s'] +2h15min00s +``` + +### Exercice 6 ⭐⭐ — Formes abstraites + +Écris une classe abstraite `Forme` avec deux méthodes abstraites `aire()` et `perimetre()`, et une méthode concrète `__str__` qui affiche le nom de la classe, l'aire et le périmètre. Crée `Cercle`, `Rectangle` et `Carre`. **Astuce** : un carré **est un** rectangle dont les deux côtés sont égaux ; utilise l'héritage et `super()` pour l'écrire en 3 lignes. + +Affiche ensuite les formes **triées par aire décroissante**. + +```python +formes = [Cercle(1), Rectangle(2, 5), Carre(3), Cercle(2.5)] +``` + +``` +Cercle aire=19.63 périmètre=15.71 +Rectangle aire=10.00 périmètre=14.00 +Carre aire= 9.00 périmètre=12.00 +Cercle aire= 3.14 périmètre= 6.28 +``` + +### Exercice 7 ⭐⭐⭐ — Composition : un lecteur de musique + +Modélise un **lecteur de musique** par composition, avec des dataclasses et des annotations complètes : + +- `Morceau` (titre, durée en secondes) ; +- `File` : une file de morceaux (composition avec `collections.deque`), méthodes `ajouter`, `suivant() -> Morceau | None`, `__len__` ; +- `Volume` : un niveau entre 0 et 100, avec `monter(pas=10)`, `baisser(pas=10)` (bornés) ; +- `Lecteur` : **contient** une `File` et un `Volume`, avec un attribut `en_cours: Morceau | None`. Méthodes `lire_suivant() -> str` et `etat() -> str`. + +```python +lecteur = Lecteur() +for titre, duree in [("Intro", 60), ("Thème", 200), ("Final", 150)]: + lecteur.file.ajouter(Morceau(titre, duree)) +lecteur.volume.monter(30) +print(lecteur.lire_suivant()) +print(lecteur.lire_suivant()) +print(lecteur.etat()) +print(lecteur.lire_suivant()) +print(lecteur.lire_suivant()) +``` + +``` +▶ Intro (1:00) +▶ Thème (3:20) +En cours : Thème | volume 80/100 | 1 morceau(x) en attente +▶ Final (2:30) +■ File vide, lecture terminée. +``` + +(Le volume démarre à 50.) Vérifie ton code avec `mypy` : il ne doit signaler aucune erreur. + +### Exercice 8 ⭐⭐⭐ — Le compte épargne + +Reprends l'idée du mini-projet du [chapitre 10](10-poo-classes-objets.md), en version simplifiée et annotée : + +- `CompteBancaire` : titulaire, `_solde` interne, propriété en lecture seule `solde`, méthodes `deposer(montant)` et `retirer(montant)` (avec `ValueError` si montant ≤ 0 ou solde insuffisant), et un `historique: list[str]`. +- `CompteEpargne(CompteBancaire)` : ajoute un `taux` annuel (ex : `0.03`) et un **plafond** (22 950 € par défaut). Redéfinis `deposer` pour refuser un dépôt qui dépasserait le plafond (puis appelle la version du parent avec `super()`). Ajoute `appliquer_interets()` qui dépose les intérêts d'**un mois** (taux / 12), arrondis au centime, en ignorant le plafond. +- `CompteCourant(CompteBancaire)` : ajoute un `decouvert` autorisé et redéfinit `retirer` en conséquence. + +```python +comptes: list[CompteBancaire] = [CompteCourant("Alice", decouvert=200), CompteEpargne("Alice", taux=0.03)] +comptes[0].deposer(100) +comptes[0].retirer(250) +comptes[1].deposer(10_000) +for _ in range(3): + comptes[1].appliquer_interets() +for compte in comptes: + print(compte) +try: + comptes[1].deposer(20_000) +except ValueError as erreur: + print("Refusé :", erreur) +``` + +``` +CompteCourant de Alice : -150.00 € +CompteEpargne de Alice : 10075.19 € +Refusé : plafond de 22950.00 € dépassé +``` + +--- + +## Mini-projet — Un jeu de rôle textuel + +### Cahier des charges + +Tu vas programmer un **combat automatique** entre une équipe de héros et une équipe de monstres, dans un fichier `rpg.py`, en mobilisant toutes les notions du chapitre. + +**Objets (composition et dataclasses)** + +1. Une dataclass `Objet` (nom, description) avec une méthode `utiliser(porteur)`. +2. Une dataclass `Potion(Objet)` avec un champ `soin` : `utiliser` rend des PV au porteur. +3. Une dataclass `Inventaire` (capacité, liste d'objets avec `default_factory`), avec `ajouter` (refuse si plein) et `retirer_premiere_potion() -> Potion | None`. + +**Personnages (héritage, classe abstraite, propriétés)** + +4. Une classe abstraite `Personnage` : nom, `pv_max`, force, défense, un `Inventaire` (composition). + - Une **propriété** `pv` dont le setter garde toujours la valeur entre 0 et `pv_max`. + - Une propriété `est_vivant` et une propriété `classe` (le nom de la classe). + - `subir_degats(degats) -> int` : dégâts réels = dégâts − défense, minimum 1. Renvoie les dégâts réels. + - `attaquer(cible)`, `jouer_tour(cible)` (boit une potion si PV < 30 %, sinon appelle `action_speciale`). + - Une méthode **abstraite** `action_speciale(cible) -> str`. +5. Trois sous-classes de héros, chacune avec un `__init__` qui appelle `super().__init__` avec ses propres statistiques : + - `Guerrier` : quand ses PV passent sous 50 %, il entre en **rage** (dégâts doublés) ; + - `Mage` : possède du **mana** ; lance une boule de feu (dégâts fixes, coûte du mana), sinon attaque normalement et recharge ; + - `Voleur` : redéfinit `subir_degats` pour **esquiver** 25 % des attaques, et porte des coups critiques. +6. Une classe `Monstre` dont l'action spéciale est une simple attaque. + +**Combat** + +7. Une fonction `combat(heros, monstres, de)` : à chaque tour, chaque personnage vivant joue contre une cible vivante tirée au hasard dans l'équipe adverse. Le combat s'arrête quand une équipe est entièrement K.O. (ou au bout de 20 tours). +8. Le hasard passe par un objet `random.Random(graine)` transmis aux personnages : avec la même graine, le combat est **reproductible** (pratique pour déboguer !). +9. Tout le code est **annoté** et passe `mypy` sans erreur. + +### Étapes suggérées + +1. Écris `Objet`, `Potion` et `Inventaire`, et teste-les seuls. +2. Écris `Personnage` avec la propriété `pv`. Teste que `perso.pv = 999` est ramené à `pv_max` et `perso.pv = -5` à 0. +3. Écris `Monstre` (la sous-classe la plus simple) et fais combattre deux monstres l'un contre l'autre. +4. Ajoute `Guerrier`, puis `Mage`, puis `Voleur`, en testant chacun. +5. Écris la fonction `combat` et une fonction `main` qui prépare les équipes. +6. Lance `mypy rpg.py` et corrige les erreurs signalées. + +### Pistes d'amélioration + +- Un mode **interactif** : le joueur choisit l'action de chaque héros (attaquer, potion, action spéciale) et la cible. +- Une classe `Arme` (dataclass) équipable, qui modifie la force : composition encore ! +- De l'**expérience** et des **niveaux** : une propriété `niveau` calculée à partir de l'expérience. +- Un `Soigneur` qui soigne l'allié le plus blessé : il faudra lui donner accès à son équipe. +- Sauvegarder l'équipe en JSON avec `asdict` et une méthode de classe `depuis_dict`. + +--- + +## Corrigés + +
Corrigé de l'exercice 1 + +```python +def initiales(prenom: str, nom: str) -> str: + return prenom[0].upper() + "." + nom[0].upper() + "." + + +def compter_voyelles(mots: list[str]) -> dict[str, int]: + resultat: dict[str, int] = {} + for mot in mots: + resultat[mot] = sum(1 for lettre in mot.lower() if lettre in "aeiouy") + return resultat + + +def chercher_age(annuaire: dict[str, int], nom: str) -> int | None: + return annuaire.get(nom) + + +print(initiales("ada", "lovelace")) +print(compter_voyelles(["python", "classe"])) +age = chercher_age({"Alice": 30}, "Bob") +print(age + 1) +``` + +`mypy` signale : + +``` +$ mypy exercice1.py +exercice1.py:19: error: Unsupported operand types for + ("None" and "int") [operator] +exercice1.py:19: note: Left operand is of type "int | None" +Found 1 error in 1 file (checked 1 source file) +``` + +Et en effet, à l'exécution, ce code plante avec `TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'`, car Bob n'est pas dans l'annuaire. mypy l'a trouvé **sans exécuter le programme**, uniquement grâce à l'annotation `-> int | None`. Correction de l'appel : + +```python +def chercher_age(annuaire: dict[str, int], nom: str) -> int | None: + return annuaire.get(nom) + + +age = chercher_age({"Alice": 30}, "Bob") +if age is None: + print("Âge inconnu") +else: + print(age + 1) +``` + +``` +Âge inconnu +``` + +Après le test `if age is None`, mypy sait que dans le `else`, `age` est forcément un `int` : plus d'erreur. Remarque aussi `resultat: dict[str, int] = {}` : un dictionnaire vide doit être annoté, sinon mypy ne peut pas deviner ce qu'il contiendra. + +
+ +
Corrigé de l'exercice 2 + +```python +class Vehicule: + def __init__(self, marque: str, vitesse_max: float) -> None: + self.marque = marque + self.vitesse_max = vitesse_max + + def decrire(self) -> str: + return f"{self.marque} (max {self.vitesse_max} km/h)" + + def temps_trajet(self, distance_km: float) -> float: + return distance_km / self.vitesse_max + + +class Velo(Vehicule): + def __init__(self, marque: str, electrique: bool = False) -> None: + super().__init__(marque, 25) + self.electrique = electrique + + def decrire(self) -> str: + texte = super().decrire() + return texte + (", électrique" if self.electrique else ", musculaire") + + +class Voiture(Vehicule): + def __init__(self, marque: str, vitesse_max: float, places: int) -> None: + super().__init__(marque, vitesse_max) + self.places = places + + def decrire(self) -> str: + return f"{super().decrire()}, {self.places} places" + + +vehicules = [Velo("Decathlon", electrique=True), Voiture("Renault", 180, places=5)] +for v in vehicules: + print(v.decrire(), f"— 100 km en {v.temps_trajet(100):.1f} h") +``` + +``` +Decathlon (max 25 km/h), électrique — 100 km en 4.0 h +Renault (max 180 km/h), 5 places — 100 km en 0.6 h +``` + +`Velo` n'a pas de paramètre de vitesse : il transmet lui-même la valeur `25` au parent. `temps_trajet` n'est écrite qu'**une fois**, dans `Vehicule`, et sert aux deux sous-classes. La boucle est polymorphe : elle appelle `decrire()` sans se soucier du type exact. + +
+ +
Corrigé de l'exercice 3 + +```python +ZERO_ABSOLU = -273.15 + + +class Temperature: + def __init__(self, celsius: float) -> None: + self.celsius = celsius # passe par le setter (validation) + + @property + def celsius(self) -> float: + return self._celsius + + @celsius.setter + def celsius(self, valeur: float) -> None: + if valeur < ZERO_ABSOLU: + raise ValueError(f"{valeur} °C est sous le zéro absolu") + self._celsius = valeur + + @property + def fahrenheit(self) -> float: + return self._celsius * 9 / 5 + 32 + + @fahrenheit.setter + def fahrenheit(self, valeur: float) -> None: + self.celsius = (valeur - 32) * 5 / 9 # réutilise le setter de celsius + + @property + def etat(self) -> str: + if self._celsius <= 0: + return "glace" + if self._celsius < 100: + return "liquide" + return "vapeur" + + +t = Temperature(25) +print(t.celsius, t.fahrenheit, t.etat) +t.fahrenheit = 212 +print(t.celsius, t.etat) +try: + t.celsius = -300 +except ValueError as erreur: + print("Erreur :", erreur) +try: + t.etat = "plasma" +except AttributeError as erreur: + print("Erreur :", erreur) +``` + +``` +25 77.0 liquide +100.0 vapeur +Erreur : -300 °C est sous le zéro absolu +Erreur : property 'etat' of 'Temperature' object has no setter +``` + +Une seule donnée est stockée (`_celsius`) ; `fahrenheit` et `etat` sont **calculées**. (mypy détecte d'ailleurs la tentative `t.etat = "plasma"` sans exécuter le code : `error: Property "etat" defined in "Temperature" is read-only`.) Le setter de `fahrenheit` passe par `self.celsius = ...` : la validation du zéro absolu s'applique donc aussi aux degrés Fahrenheit, sans la réécrire. `etat` n'a pas de setter : elle est en lecture seule. + +
+ +
Corrigé de l'exercice 4 + +```python +from dataclasses import dataclass, field + + +def formater_duree(secondes: int) -> str: + minutes, reste = divmod(secondes, 60) + return f"{minutes}:{reste:02d}" + + +@dataclass +class Morceau: + titre: str + artiste: str + duree: int + note: int = 0 + + def __post_init__(self) -> None: + if self.duree <= 0: + raise ValueError(f"durée invalide : {self.duree}") + if not 0 <= self.note <= 5: + raise ValueError(f"note invalide : {self.note} (0 à 5)") + + @property + def duree_texte(self) -> str: + return formater_duree(self.duree) + + +@dataclass +class Playlist: + nom: str + morceaux: list[Morceau] = field(default_factory=list) + + def ajouter(self, morceau: Morceau) -> None: + self.morceaux.append(morceau) + + @property + def duree_totale(self) -> str: + return formater_duree(sum(morceau.duree for morceau in self.morceaux)) + + def meilleurs(self, n: int) -> list[Morceau]: + return sorted(self.morceaux, key=lambda morceau: morceau.note, reverse=True)[:n] + + +playlist = Playlist("Road trip") +playlist.ajouter(Morceau("Bohemian Rhapsody", "Queen", 354, 5)) +playlist.ajouter(Morceau("Get Lucky", "Daft Punk", 248, 4)) +playlist.ajouter(Morceau("Take On Me", "a-ha", 225, 3)) +print(playlist.duree_totale) +for morceau in playlist.meilleurs(2): + print(morceau.titre, morceau.duree_texte) + +try: + Morceau("Silence", "John Cage", 273, 7) +except ValueError as erreur: + print("Erreur :", erreur) +``` + +``` +13:47 +Bohemian Rhapsody 5:54 +Get Lucky 4:08 +Erreur : note invalide : 7 (0 à 5) +``` + +La fonction `formater_duree` est partagée par les deux classes : inutile de la dupliquer. La `Playlist` **contient** des `Morceau` : c'est de la composition. `field(default_factory=list)` garantit que chaque playlist a sa propre liste. + +
+ +
Corrigé de l'exercice 5 + +```python +from dataclasses import dataclass + +UNITES = {"h": 3600, "m": 60, "s": 1} + + +@dataclass(order=True) +class Duree: + secondes: int + + @classmethod + def depuis_minutes(cls, minutes: float) -> "Duree": + return cls(round(minutes * 60)) + + @classmethod + def depuis_texte(cls, texte: str) -> "Duree": + """Accepte '1h30', '45min', '2h', '1h05min', '90s'.""" + propre = texte.strip().lower().replace("min", "m") + total = 0 + nombre = "" + for caractere in propre: + if caractere.isdigit(): + nombre += caractere + elif caractere in UNITES and nombre: + total += int(nombre) * UNITES[caractere] + nombre = "" + else: + raise ValueError(f"durée illisible : {texte!r}") + if nombre: # « 1h30 » : un nombre final sans unité = des minutes + total += int(nombre) * 60 + if not propre: + raise ValueError("durée vide") + return cls(total) + + @staticmethod + def formater(secondes: int) -> str: + heures, reste = divmod(secondes, 3600) + minutes, secondes = divmod(reste, 60) + return f"{heures}h{minutes:02d}min{secondes:02d}s" + + def __str__(self) -> str: + return self.formater(self.secondes) + + def __add__(self, autre: "Duree") -> "Duree": + return Duree(self.secondes + autre.secondes) + + +trajets = [Duree.depuis_texte("1h30"), Duree.depuis_texte("45min"), Duree.depuis_minutes(20)] +print([str(d) for d in sorted(trajets)]) +print(trajets[0] + trajets[1]) +for texte in ["2h", "1h05min", "90s", "3h10"]: + print(texte, "->", Duree.depuis_texte(texte)) +try: + Duree.depuis_texte("deux heures") +except ValueError as erreur: + print("Erreur :", erreur) +``` + +``` +['0h20min00s', '0h45min00s', '1h30min00s'] +2h15min00s +2h -> 2h00min00s +1h05min -> 1h05min00s +90s -> 0h01min30s +3h10 -> 3h10min00s +Erreur : durée illisible : 'deux heures' +``` + +Points clés : + +- `depuis_texte` et `depuis_minutes` sont des **constructeurs alternatifs** : ils fabriquent un objet `Duree` à partir d'autres données, et s'appellent sur la classe (`Duree.depuis_texte(...)`). +- `formater` ne dépend d'aucun objet : c'est une **méthode statique**, qu'on peut aussi utiliser seule (`Duree.formater(3600)`). +- `@dataclass(order=True)` génère les comparaisons à partir de l'unique champ `secondes`, d'où `sorted` qui fonctionne sans écrire `__lt__`. +- `__add__` est la méthode spéciale appelée par l'opérateur `+`. +- L'analyse du texte lit les caractères un par un : les chiffres s'accumulent dans `nombre`, et chaque lettre d'unité (`h`, `m`, `s`) « consomme » le nombre accumulé. On remplace d'abord `min` par `m` pour n'avoir que des unités d'une lettre. (Pour des formats plus complexes, on utiliserait des *expressions régulières*, module `re`, un sujet à part entière.) + +
+ +
Corrigé de l'exercice 6 + +```python +import math +from abc import ABC, abstractmethod + + +class Forme(ABC): + @abstractmethod + def aire(self) -> float: ... + + @abstractmethod + def perimetre(self) -> float: ... + + def __str__(self) -> str: + return f"{type(self).__name__:<12} aire={self.aire():5.2f} périmètre={self.perimetre():5.2f}" + + +class Cercle(Forme): + def __init__(self, rayon: float) -> None: + self.rayon = rayon + + def aire(self) -> float: + return math.pi * self.rayon**2 + + def perimetre(self) -> float: + return 2 * math.pi * self.rayon + + +class Rectangle(Forme): + def __init__(self, largeur: float, hauteur: float) -> None: + self.largeur = largeur + self.hauteur = hauteur + + def aire(self) -> float: + return self.largeur * self.hauteur + + def perimetre(self) -> float: + return 2 * (self.largeur + self.hauteur) + + +class Carre(Rectangle): + def __init__(self, cote: float) -> None: + super().__init__(cote, cote) + + +formes: list[Forme] = [Cercle(1), Rectangle(2, 5), Carre(3), Cercle(2.5)] +for forme in sorted(formes, key=lambda f: f.aire(), reverse=True): + print(forme) +``` + +``` +Cercle aire=19.63 périmètre=15.71 +Rectangle aire=10.00 périmètre=14.00 +Carre aire= 9.00 périmètre=12.00 +Cercle aire= 3.14 périmètre= 6.28 +``` + +`Carre` hérite de `Rectangle` et se contente d'appeler `super().__init__(cote, cote)` : il récupère gratuitement `aire` et `perimetre`. `__str__` utilise `type(self).__name__`, qui donne le nom de la **vraie** classe de l'objet, d'où `Carre` et non `Rectangle`. + +Attention, ce modèle a une limite connue : si on modifie `carre.largeur = 10`, l'objet n'est plus un carré. Pour des formes **modifiables**, la composition ou une classe `Carre` indépendante serait plus sûre ; pour des formes qu'on ne modifie pas après création, l'héritage est ici élégant. + +
+ +
Corrigé de l'exercice 7 + +```python +from collections import deque +from dataclasses import dataclass, field + + +@dataclass +class Morceau: + titre: str + duree: int + + def __str__(self) -> str: + minutes, secondes = divmod(self.duree, 60) + return f"{self.titre} ({minutes}:{secondes:02d})" + + +@dataclass +class File: + morceaux: deque[Morceau] = field(default_factory=deque) + + def ajouter(self, morceau: Morceau) -> None: + self.morceaux.append(morceau) + + def suivant(self) -> Morceau | None: + if not self.morceaux: + return None + return self.morceaux.popleft() + + def __len__(self) -> int: + return len(self.morceaux) + + +@dataclass +class Volume: + niveau: int = 50 + + def monter(self, pas: int = 10) -> None: + self.niveau = min(100, self.niveau + pas) + + def baisser(self, pas: int = 10) -> None: + self.niveau = max(0, self.niveau - pas) + + +@dataclass +class Lecteur: + file: File = field(default_factory=File) + volume: Volume = field(default_factory=Volume) + en_cours: Morceau | None = None + + def lire_suivant(self) -> str: + self.en_cours = self.file.suivant() + if self.en_cours is None: + return "■ File vide, lecture terminée." + return f"▶ {self.en_cours}" + + def etat(self) -> str: + titre = self.en_cours.titre if self.en_cours is not None else "rien" + return (f"En cours : {titre} | volume {self.volume.niveau}/100 | " + f"{len(self.file)} morceau(x) en attente") + + +lecteur = Lecteur() +for titre, duree in [("Intro", 60), ("Thème", 200), ("Final", 150)]: + lecteur.file.ajouter(Morceau(titre, duree)) +lecteur.volume.monter(30) +print(lecteur.lire_suivant()) +print(lecteur.lire_suivant()) +print(lecteur.etat()) +print(lecteur.lire_suivant()) +print(lecteur.lire_suivant()) +``` + +``` +▶ Intro (1:00) +▶ Thème (3:20) +En cours : Thème | volume 80/100 | 1 morceau(x) en attente +▶ Final (2:30) +■ File vide, lecture terminée. +``` + +``` +$ mypy lecteur.py +Success: no issues found in 1 source file +``` + +Aucune classe n'hérite d'une autre : tout est **composition**. Le `Lecteur` **a une** file et **un** volume, et leur délègue le travail. `field(default_factory=File)` fonctionne avec n'importe quelle classe, pas seulement `list`. L'annotation `Morceau | None` force à vérifier `is None` avant d'utiliser `en_cours`, et mypy le contrôle. + +
+ +
Corrigé de l'exercice 8 + +```python +class CompteBancaire: + def __init__(self, titulaire: str) -> None: + self.titulaire = titulaire + self._solde = 0.0 + self.historique: list[str] = [] + + @property + def solde(self) -> float: + return self._solde + + def deposer(self, montant: float) -> None: + if montant <= 0: + raise ValueError("le montant doit être positif") + self._solde += montant + self.historique.append(f"+{montant:.2f}") + + def retirer(self, montant: float) -> None: + if montant <= 0: + raise ValueError("le montant doit être positif") + if montant > self._solde: + raise ValueError("solde insuffisant") + self._solde -= montant + self.historique.append(f"-{montant:.2f}") + + def __str__(self) -> str: + return f"{type(self).__name__} de {self.titulaire} : {self._solde:.2f} €" + + +class CompteEpargne(CompteBancaire): + def __init__(self, titulaire: str, taux: float, plafond: float = 22_950) -> None: + super().__init__(titulaire) + self.taux = taux + self.plafond = plafond + + def deposer(self, montant: float) -> None: + if self._solde + montant > self.plafond: + raise ValueError(f"plafond de {self.plafond:.2f} € dépassé") + super().deposer(montant) + + def appliquer_interets(self) -> None: + interets = round(self._solde * self.taux / 12, 2) + if interets > 0: + super().deposer(interets) # on contourne volontairement le plafond + + +class CompteCourant(CompteBancaire): + def __init__(self, titulaire: str, decouvert: float = 0) -> None: + super().__init__(titulaire) + self.decouvert = decouvert + + def retirer(self, montant: float) -> None: + if montant <= 0: + raise ValueError("le montant doit être positif") + if montant > self._solde + self.decouvert: + raise ValueError(f"découvert de {self.decouvert:.2f} € dépassé") + self._solde -= montant + self.historique.append(f"-{montant:.2f}") + + +comptes: list[CompteBancaire] = [CompteCourant("Alice", decouvert=200), CompteEpargne("Alice", taux=0.03)] +comptes[0].deposer(100) +comptes[0].retirer(250) +comptes[1].deposer(10_000) +for _ in range(3): + comptes[1].appliquer_interets() +for compte in comptes: + print(compte) +try: + comptes[1].deposer(20_000) +except ValueError as erreur: + print("Refusé :", erreur) +print(comptes[1].historique) +``` + +``` +CompteCourant de Alice : -150.00 € +CompteEpargne de Alice : 10075.19 € +Refusé : plafond de 22950.00 € dépassé +['+10000.00', '+25.00', '+25.06', '+25.13'] +``` + +Points clés : + +- `CompteEpargne.deposer` **ajoute une vérification** puis délègue le reste au parent avec `super().deposer(montant)` : la validation « montant positif » et l'historique ne sont pas réécrits. +- `appliquer_interets` appelle `super().deposer(...)` **directement**, ce qui saute la vérification du plafond de `CompteEpargne` : les intérêts peuvent faire dépasser le plafond, comme sur un vrai livret. +- Les intérêts sont composés : chaque mois, ils sont calculés sur un solde un peu plus élevé (25,00 puis 25,06 puis 25,13). +- `comptes: list[CompteBancaire]` contient des sous-classes différentes : c'est le polymorphisme. Mais attention, mypy signale `comptes[1].appliquer_interets()` : pour lui, `comptes[1]` est un `CompteBancaire`, qui n'a pas cette méthode ! Le programme fonctionne, mais mypy affiche `error: "CompteBancaire" has no attribute "appliquer_interets" [attr-defined]`. Une solution propre consiste à garder une variable typée précisément : `epargne = CompteEpargne(...)`, ou à tester `if isinstance(compte, CompteEpargne):` avant l'appel. + +
+ +
Corrigé du mini-projet + +```python +"""Petit jeu de rôle textuel : héritage, composition, propriétés, typage.""" + +import random +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + + +# ---------- Objets et inventaire (composition) ---------- +@dataclass +class Objet: + nom: str + description: str = "" + + def utiliser(self, porteur: "Personnage") -> str: + return f"{porteur.nom} regarde {self.nom}, sans effet." + + +@dataclass +class Potion(Objet): + soin: int = 15 + + def utiliser(self, porteur: "Personnage") -> str: + avant = porteur.pv + porteur.pv += self.soin + return f"{porteur.nom} boit {self.nom} (+{porteur.pv - avant} PV)" + + +@dataclass +class Inventaire: + capacite: int = 5 + objets: list[Objet] = field(default_factory=list) + + def ajouter(self, objet: Objet) -> None: + if len(self.objets) >= self.capacite: + raise ValueError("inventaire plein") + self.objets.append(objet) + + def retirer_premiere_potion(self) -> Potion | None: + for objet in self.objets: + if isinstance(objet, Potion): + self.objets.remove(objet) + return objet + return None + + def __len__(self) -> int: + return len(self.objets) + + +# ---------- Hiérarchie de personnages (héritage) ---------- +class Personnage(ABC): + def __init__(self, nom: str, pv_max: int, force: int, defense: int, + de: random.Random | None = None) -> None: + self.nom = nom + self.pv_max = pv_max + self._pv = pv_max + self.force = force + self.defense = defense + self.inventaire = Inventaire() + self.de = de if de is not None else random.Random() + + # --- propriété : les PV restent toujours entre 0 et pv_max --- + @property + def pv(self) -> int: + return self._pv + + @pv.setter + def pv(self, valeur: int) -> None: + self._pv = max(0, min(self.pv_max, valeur)) + + @property + def est_vivant(self) -> bool: + return self._pv > 0 + + @property + def classe(self) -> str: + return type(self).__name__ + + # --- comportements communs --- + def subir_degats(self, degats: int) -> int: + reels = max(1, degats - self.defense) + self.pv -= reels + return reels + + @staticmethod + def decrire(degats: int) -> str: + return f"-{degats} PV" if degats > 0 else "esquivé !" + + def attaquer(self, cible: "Personnage") -> str: + degats = cible.subir_degats(self.force + self.de.randint(0, 3)) + return f"{self.nom} attaque {cible.nom} : {self.decrire(degats)}" + + def jouer_tour(self, cible: "Personnage") -> str: + """Choix automatique : se soigner si besoin, sinon action de classe.""" + if self.pv < self.pv_max * 0.3: + potion = self.inventaire.retirer_premiere_potion() + if potion is not None: + return potion.utiliser(self) + return self.action_speciale(cible) + + @abstractmethod + def action_speciale(self, cible: "Personnage") -> str: + """Chaque classe de personnage DOIT définir son action.""" + + def __str__(self) -> str: + etat = f"{self.pv:>3}/{self.pv_max}" if self.est_vivant else "K.O." + return f"{self.nom} ({self.classe}) {etat}" + + +class Guerrier(Personnage): + def __init__(self, nom: str, de: random.Random | None = None) -> None: + super().__init__(nom, pv_max=45, force=8, defense=3, de=de) + + def action_speciale(self, cible: Personnage) -> str: + if self.pv < self.pv_max / 2: # rage ! + degats = cible.subir_degats(self.force * 2) + return f"{self.nom} entre en RAGE et frappe {cible.nom} : {self.decrire(degats)}" + return self.attaquer(cible) + + +class Mage(Personnage): + COUT_BOULE_DE_FEU = 10 + + def __init__(self, nom: str, de: random.Random | None = None) -> None: + super().__init__(nom, pv_max=30, force=4, defense=1, de=de) + self.mana = 30 + + def action_speciale(self, cible: Personnage) -> str: + if self.mana >= self.COUT_BOULE_DE_FEU: + self.mana -= self.COUT_BOULE_DE_FEU + degats = cible.subir_degats(14) + return f"{self.nom} lance une boule de feu sur {cible.nom} : {self.decrire(degats)}" + self.mana += 5 + return self.attaquer(cible) + " (recharge son mana)" + + def __str__(self) -> str: + return super().__str__() + f" mana {self.mana}" + + +class Voleur(Personnage): + def __init__(self, nom: str, de: random.Random | None = None) -> None: + super().__init__(nom, pv_max=35, force=6, defense=2, de=de) + + def subir_degats(self, degats: int) -> int: + if self.de.random() < 0.25: # esquive + return 0 + return super().subir_degats(degats) + + def action_speciale(self, cible: Personnage) -> str: + if self.de.random() < 0.3: + degats = cible.subir_degats(self.force * 3) + return f"{self.nom} porte un COUP CRITIQUE à {cible.nom} : {self.decrire(degats)}" + return self.attaquer(cible) + + +class Monstre(Personnage): + def __init__(self, nom: str, pv_max: int, force: int, defense: int, + de: random.Random | None = None) -> None: + super().__init__(nom, pv_max, force, defense, de) + + def action_speciale(self, cible: Personnage) -> str: + return self.attaquer(cible) + + +# ---------- Le combat ---------- +def vivants(equipe: list[Personnage]) -> list[Personnage]: + return [membre for membre in equipe if membre.est_vivant] + + +def combat(heros: list[Personnage], monstres: list[Personnage], + de: random.Random, tours_max: int = 20) -> str: + for numero_tour in range(1, tours_max + 1): + print(f"--- Tour {numero_tour} ---") + for acteur in heros + monstres: + if not acteur.est_vivant: + continue + adversaires = vivants(monstres if acteur in heros else heros) + if not adversaires: + break + cible = de.choice(adversaires) + print(" " + acteur.jouer_tour(cible)) + if not vivants(monstres): + return "Victoire des héros !" + if not vivants(heros): + return "Les monstres l'emportent..." + return "Match nul : tout le monde est épuisé." + + +def main(graine: int = 4) -> None: + de = random.Random(graine) + heros: list[Personnage] = [Guerrier("Brunhild", de), Mage("Elric", de), Voleur("Sly", de)] + for personnage in heros: + personnage.inventaire.ajouter(Potion("Petite potion", soin=15)) + monstres: list[Personnage] = [ + Monstre("Ogre", 80, 11, 3, de), + Monstre("Gobelin", 30, 7, 1, de), + Monstre("Gobelin", 30, 7, 1, de), + ] + print("Héros :", ", ".join(str(p) for p in heros)) + print("Monstres :", ", ".join(str(m) for m in monstres)) + resultat = combat(heros, monstres, de) + print("=" * 40) + print(resultat) + for personnage in heros + monstres: + print(" ", personnage) + + +if __name__ == "__main__": + main() +``` + +Sortie avec la graine 4 (identique à chaque exécution) : + +``` +Héros : Brunhild (Guerrier) 45/45, Elric (Mage) 30/30 mana 30, Sly (Voleur) 35/35 +Monstres : Ogre (Monstre) 80/80, Gobelin (Monstre) 30/30, Gobelin (Monstre) 30/30 +--- Tour 1 --- + Brunhild attaque Ogre : -7 PV + Elric lance une boule de feu sur Ogre : -11 PV + Sly attaque Gobelin : -6 PV + Ogre attaque Brunhild : -8 PV + Gobelin attaque Brunhild : -7 PV + Gobelin attaque Sly : -7 PV +--- Tour 2 --- + Brunhild attaque Ogre : -6 PV + Elric lance une boule de feu sur Gobelin : -13 PV + Sly attaque Gobelin : -6 PV + Ogre attaque Brunhild : -10 PV + Gobelin attaque Brunhild : -4 PV + Gobelin attaque Sly : -7 PV +--- Tour 3 --- + Brunhild entre en RAGE et frappe Ogre : -13 PV + Elric lance une boule de feu sur Ogre : -11 PV + Sly porte un COUP CRITIQUE à Gobelin : -17 PV + Ogre attaque Sly : esquivé ! + Gobelin attaque Sly : -7 PV + Gobelin attaque Sly : esquivé ! +--- Tour 4 --- + Brunhild entre en RAGE et frappe Gobelin : -15 PV + Elric attaque Gobelin : -3 PV (recharge son mana) + Sly porte un COUP CRITIQUE à Gobelin : -17 PV + Ogre attaque Elric : -12 PV +--- Tour 5 --- + Brunhild entre en RAGE et frappe Ogre : -13 PV + Elric attaque Ogre : -4 PV (recharge son mana) + Sly attaque Ogre : -4 PV + Ogre attaque Brunhild : -10 PV +--- Tour 6 --- + Brunhild boit Petite potion (+15 PV) + Elric lance une boule de feu sur Ogre : -11 PV +======================================== +Victoire des héros ! + Brunhild (Guerrier) 21/45 + Elric (Mage) 18/30 mana 0 + Sly (Voleur) 14/35 + Ogre (Monstre) K.O. + Gobelin (Monstre) K.O. + Gobelin (Monstre) K.O. +``` + +Vérification des types, même en mode strict : + +``` +$ mypy --strict rpg.py +Success: no issues found in 1 source file +``` + +Points importants : + +- **La propriété `pv`** est le cœur de la robustesse : `porteur.pv += self.soin` dans `Potion` ou `self.pv -= reels` dans `subir_degats` ne peuvent jamais sortir des bornes, quel que soit le code qui modifie les PV. Le calcul `porteur.pv - avant` donne le soin **réel** (une potion ne soigne pas au-delà du maximum). +- **Héritage** : chaque héros ne définit que ce qui le distingue. Le `Voleur` redéfinit `subir_degats` et appelle `super().subir_degats(...)` quand il n'esquive pas ; le `Mage` complète `__str__` avec `super().__str__()`. +- **Classe abstraite** : impossible de créer un `Personnage()` générique, et une nouvelle classe qui oublierait `action_speciale` serait refusée dès sa création. +- **Polymorphisme** : `combat` et `jouer_tour` appellent `action_speciale` sans jamais tester le type du personnage. Ajouter une classe `Paladin` ne demande **aucune** modification de `combat`. +- **Composition** : chaque personnage **a un** `Inventaire`, qui **a des** `Objet`. `Potion` hérite d'`Objet` (une potion **est un** objet), mais `Personnage` n'hérite pas d'`Inventaire`. +- **Hasard reproductible** : un seul objet `random.Random(graine)` est partagé par tout le jeu. Avec la même graine, on obtient exactement le même combat, ce qui permet de reproduire un bug. Change la graine dans `main(...)` pour voir d'autres combats. +- `decrire` est une **méthode statique** : elle ne dépend d'aucun personnage, mais elle appartient logiquement à la classe. +- Les annotations entre guillemets (`"Personnage"`) sont nécessaires dans `Objet` et `Potion`, car la classe `Personnage` est définie **plus bas** dans le fichier. + +
+ +--- + +## Récapitulatif + +- Les **annotations de type** documentent ton code : `nom: str`, `def f(x: int) -> float:`, `list[int]`, `dict[str, float]`, `tuple[int, int]`, `X | None`, `-> None`. Python les **ignore à l'exécution** ; **mypy** (et VS Code) les vérifie sans lancer le programme. +- L'**héritage** (`class Enfant(Parent):`) permet de réutiliser le code d'une classe. L'enfant peut **ajouter** des méthodes et en **redéfinir**. `super()` donne accès à la version du parent, notamment `super().__init__(...)`, à ne jamais oublier. +- `isinstance(objet, Classe)` tient compte de l'héritage ; toutes les classes héritent de `object`. Les exceptions personnalisées héritent de `Exception`. +- Le **polymorphisme** : des objets différents répondent à la même méthode chacun à leur façon ; le code qui les utilise n'a pas à connaître leur type exact. +- Une **classe abstraite** (`ABC` + `@abstractmethod`) impose aux sous-classes de définir certaines méthodes et ne peut pas être instanciée. +- **« Est un » → héritage ; « a un » → composition.** Dans le doute, préfère la composition. +- `@property` transforme une méthode en attribut calculé ; `@nom.setter` permet de valider les affectations. La valeur réelle est stockée dans un attribut interne au nom différent (`_nom`). +- `@classmethod` (reçoit `cls`) sert surtout aux constructeurs alternatifs ; `@staticmethod` (ne reçoit rien) est une fonction utilitaire rangée dans la classe. +- `@dataclass` génère `__init__`, `__repr__` et `__eq__` ; `field(default_factory=list)` pour les listes ; `__post_init__` pour valider ; options `order=True`, `frozen=True` ; `asdict()` pour convertir en dictionnaire. + +### Checklist de validation + +- [ ] Je sais annoter une fonction avec des paramètres de type `list[...]`, `dict[..., ...]` et un retour `X | None`. +- [ ] J'ai lancé mypy sur un de mes fichiers et j'ai compris ses messages. +- [ ] Je sais créer une sous-classe qui appelle `super().__init__()` et redéfinit une méthode. +- [ ] Je sais expliquer le polymorphisme avec un exemple de boucle sur des objets différents. +- [ ] Je sais choisir entre héritage et composition avec le test « est un / a un ». +- [ ] Je sais écrire une propriété avec un setter qui valide la valeur, sans boucle infinie. +- [ ] Je sais quand utiliser `@classmethod` et `@staticmethod`. +- [ ] Je sais écrire une dataclass avec une liste par défaut et une validation dans `__post_init__`. +- [ ] Mon jeu de rôle fonctionne, est reproductible avec une graine, et passe mypy. + +--- + +[← Chapitre précédent](10-poo-classes-objets.md) | [Sommaire](README.md) | [Chapitre suivant →](12-outils-venv-pip-git.md) diff --git a/docs/presentations/Python/12-outils-venv-pip-git.md b/docs/presentations/Python/12-outils-venv-pip-git.md new file mode 100644 index 0000000..e351653 --- /dev/null +++ b/docs/presentations/Python/12-outils-venv-pip-git.md @@ -0,0 +1,1524 @@ +# Chapitre 12 — Les outils du développeur : venv, pip et Git + +> **⏱️ Durée estimée** : 5 à 7 heures (cours + exercices + mini-projet) +> +> **📋 Prérequis** : avoir terminé le [Chapitre 11 — POO avancée, héritage et typage](11-poo-avancee-heritage-typage.md) et savoir utiliser un terminal ([Chapitre 00](00-installation-environnement.md)). +> +> **🎯 Objectifs** — À la fin de ce chapitre, tu sauras… +> +> - créer, activer et désactiver un **environnement virtuel** (venv) sous Windows, macOS et Linux ; +> - installer, lister, désinstaller des paquets avec **pip** et partager tes dépendances avec un fichier **requirements.txt** ; +> - situer les outils modernes **uv** et **pyproject.toml** ; +> - suivre l'historique de ton code avec **Git** : `init`, `status`, `add`, `commit`, `log`, `diff` ; +> - travailler avec des **branches**, les fusionner et résoudre un conflit simple ; +> - publier ton projet sur **GitHub** et le récupérer ailleurs avec `clone`, `push` et `pull`. + +--- + +## Cours + +Jusqu'ici, tu as écrit du code Python « tout seul dans ton coin » : un fichier, tu l'exécutes, c'est tout. Les vrais projets ont deux besoins supplémentaires : + +1. **Utiliser du code écrit par d'autres** (des *bibliothèques* comme `requests`, `pandas`, `flask`…) sans casser ton ordinateur ni tes autres projets. → C'est le rôle de **venv** et **pip**. +2. **Garder l'historique** de ton travail, pouvoir revenir en arrière, essayer des idées sans risque et collaborer. → C'est le rôle de **Git** et **GitHub**. + +Ces outils ne sont pas du Python à proprement parler, mais **aucun développeur ne travaille sans eux**. Prends le temps de bien les comprendre : tu t'en serviras dans tous tes projets à venir. + +### 1. Pourquoi des environnements virtuels ? + +Imagine une cuisine partagée par toute une colocation. Chacun range ses ingrédients dans le même placard. Un jour, Léa remplace le sel par du sel au céleri pour sa recette… et le gâteau de Tom a un goût étrange. 🤢 + +C'est exactement ce qui arrive quand tu installes toutes tes bibliothèques dans le Python « global » de ton ordinateur : + +- le projet A a besoin de `pandas` version 1.x ; +- le projet B a besoin de `pandas` version 2.x ; +- impossible d'avoir les deux en même temps dans le même placard ! + +Un **environnement virtuel** (*virtual environment*, abrégé **venv**) est un **placard privé par projet** : un dossier qui contient sa propre copie de l'interpréteur Python et ses propres bibliothèques. Ce que tu installes dedans ne touche ni les autres projets, ni le Python du système. + +```text +mon_projet/ +├── .venv/ ← le placard privé (ne se partage pas, ne se commit pas) +│ ├── bin/ ou Scripts/ ← python, pip… de ce projet +│ └── lib/ ← les bibliothèques installées pour ce projet +├── main.py +└── requirements.txt ← la liste de courses (elle, on la partage) +``` + +> 💡 **Règle d'or** : **un projet = un venv**. On crée le venv dans le dossier du projet, et on l'appelle par convention `.venv` (avec un point, ce qui le cache sur macOS/Linux et le rend facile à reconnaître). + +### 2. Créer, activer et désactiver un venv + +#### 2.1 Créer le venv + +Ouvre un terminal **dans le dossier de ton projet** (dans VS Code : *Terminal → Nouveau terminal*), puis : + +```bash +# macOS / Linux +python3 -m venv .venv +``` + +```powershell +# Windows (PowerShell ou cmd) +py -m venv .venv +``` + +Décortiquons : `python3 -m venv` signifie « exécute le module `venv` de Python », et `.venv` est le nom du dossier à créer. L'opération prend quelques secondes ; un dossier `.venv` apparaît. + +#### 2.2 Activer le venv + +Créer le venv ne suffit pas : il faut **l'activer** pour que les commandes `python` et `pip` du terminal pointent vers le placard privé. + +```bash +# macOS / Linux (bash, zsh) +source .venv/bin/activate +``` + +```powershell +# Windows PowerShell +.venv\Scripts\Activate.ps1 +``` + +```bat +:: Windows "Invite de commandes" (cmd.exe) +.venv\Scripts\activate.bat +``` + +Une fois activé, le nom du venv s'affiche **au début de l'invite** du terminal : + +```text +(.venv) camille@portable:~/mon_projet$ +``` + +C'est ton signal visuel : « je suis dans le placard privé ». À partir de là, sur tous les systèmes, tu peux simplement taper `python` et `pip` (plus besoin de `python3` ou `py`). + +> ⚠️ **Windows : « l'exécution de scripts est désactivée sur ce système »** +> La première fois, PowerShell peut refuser d'exécuter `Activate.ps1`. Lance une seule fois cette commande, puis réessaie : +> +> ```powershell +> Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +> ``` +> +> Elle autorise les scripts locaux pour ton utilisateur uniquement. + +#### 2.3 Vérifier qu'on est bien dans le venv + +```bash +python -c "import sys; print(sys.prefix)" +``` + +Si le chemin affiché se termine par `.venv`, c'est gagné. Autre méthode : + +```bash +# macOS / Linux +which python +``` + +```powershell +# Windows PowerShell +Get-Command python +``` + +#### 2.4 Désactiver le venv + +```bash +deactivate +``` + +Cette commande est la même partout. Le `(.venv)` disparaît de l'invite. Fermer le terminal désactive aussi le venv. + +#### 2.5 VS Code et le venv + +VS Code détecte généralement le dossier `.venv` tout seul et te propose de l'utiliser. Sinon : `Ctrl+Maj+P` (ou `Cmd+Maj+P` sur Mac) → **Python: Select Interpreter** → choisis celui qui contient `.venv`. Ensuite, chaque nouveau terminal ouvert dans VS Code active automatiquement le venv. + +#### 2.6 Supprimer un venv + +Un venv est **jetable** : si tu as un doute, supprime le dossier `.venv` et recrée-le. Comme tes dépendances sont listées dans `requirements.txt` (voir plus bas), tu ne perds rien. + +```bash +# macOS / Linux +rm -rf .venv +``` + +```powershell +# Windows PowerShell +Remove-Item -Recurse -Force .venv +``` + +### 3. pip : le gestionnaire de paquets + +**pip** (*Package Installer for Python*) télécharge des bibliothèques depuis **PyPI** (*Python Package Index*, [pypi.org](https://pypi.org)), l'immense catalogue public de paquets Python : plus de 500 000 projets ! + +> 💡 Analogie : PyPI est le supermarché, pip est ton chariot, le venv est ton placard. + +Toutes les commandes ci-dessous s'exécutent **venv activé**. + +#### 3.1 Installer un paquet + +```bash +pip install requests +``` + +pip télécharge `requests` **et ses dépendances** (les bibliothèques dont `requests` a lui-même besoin). Tu peux ensuite l'utiliser : + +```python +import requests + +reponse = requests.get("https://api.github.com") +print(reponse.status_code) # 200 si tout va bien +``` + +Variantes utiles : + +```bash +pip install requests==2.32.3 # une version précise +pip install "requests>=2.30" # au moins cette version (guillemets : à cause du >) +pip install --upgrade requests # mettre à jour vers la dernière version +pip install rich pandas # plusieurs paquets d'un coup +``` + +> 💡 **Astuce robuste** : `python -m pip install requests` est équivalent à `pip install requests`, mais garantit que tu utilises le pip **du Python actuel**. En cas de doute (surtout sous Windows), préfère cette forme. + +#### 3.2 Lister et inspecter + +```bash +pip list # tous les paquets installés, présentation lisible +pip show requests # détails : version, auteur, dépendances, emplacement… +``` + +Exemple de sortie de `pip list` dans un venv tout neuf où l'on a installé `requests` : + +```text +Package Version +------------------ --------- +certifi ... +charset-normalizer ... +idna ... +pip ... +requests ... +urllib3 ... +``` + +Tu remarques que `certifi`, `idna`, `urllib3`… ont été installés automatiquement : ce sont les dépendances de `requests`. + +#### 3.3 Désinstaller + +```bash +pip uninstall requests # demande confirmation (y/n) +pip uninstall -y requests # sans confirmation +``` + +⚠️ `pip uninstall` ne retire **pas** les dépendances qui avaient été installées avec le paquet. Si tu veux repartir de zéro, le plus simple reste de supprimer et recréer le venv. + +#### 3.4 `pip freeze` et le fichier requirements.txt + +Comment dire à un ami (ou à ton « toi » du futur) quelles bibliothèques installer pour faire tourner ton projet ? On écrit une **liste de courses** : le fichier `requirements.txt`. + +```bash +pip freeze +``` + +affiche tous les paquets installés au format `nom==version` : + +```text +certifi==2026.7.22 +charset-normalizer==3.5.2 +idna==3.20 +requests==2.34.2 +urllib3==2.8.0 +``` + +On redirige cette sortie dans un fichier avec `>` : + +```bash +pip freeze > requirements.txt +``` + +Et de l'autre côté, sur un autre ordinateur, après avoir créé et activé un venv : + +```bash +pip install -r requirements.txt +``` + +`-r` signifie « lis la liste dans ce fichier ». En une commande, on retrouve **exactement** les mêmes versions. 🎉 + +Tu peux aussi écrire `requirements.txt` à la main, avec seulement les paquets que tu utilises directement : + +```text +# requirements.txt — dépendances de mon projet +requests>=2.30 +rich +pandas==2.2.2 +``` + +| Notation | Signification | +|---|---| +| `rich` | n'importe quelle version (la plus récente) | +| `pandas==2.2.2` | exactement cette version (*épinglée*) | +| `requests>=2.30` | au moins 2.30 | +| `flask~=3.0` | compatible avec 3.0 (donc 3.x, mais pas 4.0) | + +> 💡 **freeze ou à la main ?** `pip freeze` donne une reproduction exacte (idéal pour déployer une application). Une liste écrite à la main est plus lisible. Pour débuter, `pip freeze > requirements.txt` est parfaitement acceptable. + +#### 3.5 Le cycle de vie complet + +```bash +# 1. Je crée mon projet et son venv +mkdir meteo && cd meteo +python3 -m venv .venv # Windows : py -m venv .venv +source .venv/bin/activate # Windows : .venv\Scripts\Activate.ps1 + +# 2. J'installe ce dont j'ai besoin +pip install requests rich + +# 3. Je code… puis je sauvegarde la liste +pip freeze > requirements.txt + +# 4. Je termine ma session +deactivate +``` + +Et pour quelqu'un qui récupère le projet : + +```bash +cd meteo +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +### 4. L'alternative moderne : pyproject.toml et uv + +Tu croiseras ces deux noms partout ; voici l'essentiel pour ne pas être perdu. + +#### 4.1 pyproject.toml + +`pyproject.toml` est le **fichier de configuration standard** d'un projet Python moderne. Il remplace (et dépasse) `requirements.txt` : il décrit le nom du projet, sa version, la version de Python requise, ses dépendances, et la configuration des outils (pytest, ruff… que tu verras au [chapitre 13](13-tests-pytest-bonnes-pratiques.md)). + +```toml +[project] +name = "meteo" +version = "0.1.0" +description = "Affiche la météo dans le terminal" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "requests>=2.30", + "rich", +] + +[project.optional-dependencies] +dev = ["pytest", "ruff"] +``` + +Le format TOML se lit facilement : des sections entre crochets `[...]` et des paires `clé = valeur`. + +#### 4.2 uv + +**uv** est un outil récent, extrêmement rapide, qui remplace en une seule commande `venv` + `pip` (et bien plus : il peut même installer Python pour toi). Il s'installe à part (voir [docs.astral.sh/uv](https://docs.astral.sh/uv/)). Un aperçu : + +```bash +uv init meteo # crée le projet avec un pyproject.toml +cd meteo +uv add requests rich # ajoute les dépendances (et crée le .venv tout seul) +uv run main.py # exécute le script dans le venv, sans l'activer +``` + +Il sait aussi imiter les commandes classiques : + +```bash +uv venv # crée .venv +uv pip install -r requirements.txt # comme pip, en beaucoup plus rapide +``` + +> 🧭 **Que choisir ?** Apprends d'abord `venv` + `pip` + `requirements.txt` : ils sont livrés avec Python, fonctionnent partout et t'aident à comprendre ce qui se passe. Quand tu seras à l'aise, essaie `uv` : tu retrouveras les mêmes concepts, en plus confortable. + +### 5. Git : la machine à remonter le temps + +Tu as sûrement déjà vu des dossiers comme ça : + +```text +rapport.docx +rapport_v2.docx +rapport_v2_corrige.docx +rapport_FINAL.docx +rapport_FINAL_vraiment.docx +``` + +**Git** résout ce problème proprement. C'est un **logiciel de gestion de versions** : il enregistre des **photos** successives de ton projet (appelées *commits*), avec un message expliquant ce qui a changé. Tu peux à tout moment : + +- voir l'historique complet ; +- comparer deux versions ; +- revenir en arrière ; +- essayer une idée dans une « réalité parallèle » (une *branche*) sans risque ; +- collaborer à plusieurs sur le même code. + +> 💡 **Git ≠ GitHub.** Git est le logiciel qui tourne sur ton ordinateur. GitHub est un **site web** qui héberge des dépôts Git en ligne (comme GitLab, Codeberg ou Bitbucket). Tu peux utiliser Git sans GitHub, mais pas l'inverse. + +#### 5.1 Installer et configurer Git + +- **Windows** : télécharge « Git for Windows » sur [git-scm.com](https://git-scm.com) et garde les options par défaut (il installe aussi *Git Bash*). +- **macOS** : tape `git --version` dans le terminal ; macOS propose d'installer les outils nécessaires. Ou `brew install git`. +- **Linux** : `sudo apt install git` (Debian/Ubuntu) ou `sudo dnf install git` (Fedora). + +Vérifie : `git --version`. + +Ensuite, présente-toi à Git **une seule fois** (ces infos apparaissent dans chaque commit) : + +```bash +git config --global user.name "Camille Martin" +git config --global user.email "camille@example.com" +git config --global init.defaultBranch main +``` + +La troisième ligne nomme `main` la branche principale des nouveaux dépôts (c'est le nom utilisé par GitHub). Pour voir ta configuration : `git config --list`. + +> 💡 Si tu comptes publier sur GitHub, utilise la même adresse e-mail que ton compte GitHub (ou l'adresse anonyme `...@users.noreply.github.com` que GitHub te fournit dans ses réglages). + +#### 5.2 Les trois zones de Git + +C'est LE concept à comprendre. Pense à un **photographe** qui prépare une photo de groupe : + +| Zone | Analogie | Commande pour y envoyer | +|---|---|---| +| **Répertoire de travail** (*working directory*) | les gens qui se baladent dans la pièce | tu modifies tes fichiers | +| **Zone de préparation** (*staging area / index*) | les gens que tu places devant l'objectif | `git add` | +| **Dépôt** (*repository*, dossier `.git`) | l'album photo | `git commit` | + +```text + fichiers modifiés ──git add──▶ zone de préparation ──git commit──▶ historique +``` + +Pourquoi une étape intermédiaire ? Pour **choisir** ce qui va dans chaque photo. Si tu as corrigé un bug ET commencé une nouvelle fonction, tu peux faire deux commits séparés, chacun avec un message clair. + +#### 5.3 Créer un dépôt : `git init` + +```bash +cd mon_projet +git init +``` + +Git crée un dossier caché `.git` : c'est là qu'il stocke tout l'historique. **Ne le modifie jamais à la main.** Supprimer `.git` = supprimer tout l'historique (tes fichiers restent, mais ne sont plus suivis). + +#### 5.4 Observer : `git status` + +`git status` est ta boussole. Lance-la **tout le temps**, avant et après chaque commande. + +```bash +git status +``` + +```text +On branch main + +No commits yet + +Untracked files: + (use "git add ..." to include in what will be committed) + .gitignore + main.py + +nothing added to commit but untracked files present (use "git add" to track) +``` + +Git te dit : « je vois deux fichiers, mais je ne les suis pas encore (*untracked*) ». Et il te dit même quoi faire ! + +#### 5.5 Préparer : `git add` + +```bash +git add main.py # un fichier précis +git add src/ # tout un dossier +git add . # tout ce qui a changé dans le dossier courant +``` + +Pour retirer un fichier de la zone de préparation (sans perdre tes modifications) : + +```bash +git restore --staged main.py +``` + +#### 5.6 Enregistrer : `git commit` + +```bash +git commit -m "Premier commit : script de base" +``` + +```text +[main (root-commit) b83e225] Premier commit : script de base + 2 files changed, 5 insertions(+) + create mode 100644 .gitignore + create mode 100644 main.py +``` + +`b83e225` est l'**identifiant** (le *hash*) du commit, une empreinte unique. Chaque commit connaît son « parent » : l'historique forme une chaîne. + +**Écrire un bon message de commit :** + +- court (moins de 70 caractères environ) ; +- à l'impératif ou à l'indicatif, qui décrit **ce que fait** le commit : « Ajoute le calcul de la moyenne », « Corrige la division par zéro » ; +- ❌ à éviter : « modif », « wip », « ça marche enfin », « azerty ». + +Raccourci pour les fichiers **déjà suivis** : `git commit -am "message"` fait `add` + `commit` d'un coup (mais n'ajoute pas les nouveaux fichiers). + +> 🎯 **Quand committer ?** Dès que tu as une petite étape **qui fonctionne** : une fonction terminée, un bug corrigé. Mieux vaut beaucoup de petits commits qu'un énorme commit « tout le projet ». + +#### 5.7 Consulter l'historique : `git log` + +```bash +git log # historique détaillé (q pour quitter) +git log --oneline # une ligne par commit +git log --oneline --graph # avec un dessin des branches +``` + +```text +4f5b154 Personnalise la salutation +b83e225 Premier commit : script de base +``` + +> 💡 `git log` s'ouvre dans un afficheur : flèches pour défiler, **`q` pour quitter**. Beaucoup de débutants restent coincés ici ! + +#### 5.8 Voir les différences : `git diff` + +```bash +git diff # modifications pas encore préparées (non « add ») +git diff --staged # modifications préparées, prêtes à être commitées +git diff b83e225 # différences entre ce commit et maintenant +``` + +```diff +diff --git a/main.py b/main.py +--- a/main.py ++++ b/main.py +@@ -1 +1,2 @@ +-print("Bonjour") ++nom = input("Nom ? ") ++print(f"Bonjour {nom}") +``` + +Les lignes en `-` (rouge) ont été supprimées, celles en `+` (vert) ajoutées. + +#### 5.9 Annuler des choses + +| Je veux… | Commande | +|---|---| +| annuler mes modifications d'un fichier (revenir au dernier commit) | `git restore main.py` ⚠️ perte définitive des modifs | +| retirer un fichier de la zone de préparation | `git restore --staged main.py` | +| voir un ancien fichier sans rien casser | `git show b83e225:main.py` | +| corriger le message du **dernier** commit (pas encore publié) | `git commit --amend -m "Nouveau message"` | +| annuler un commit en créant un commit inverse (sûr, même publié) | `git revert 4f5b154` | + +> ⚠️ Tu verras parfois `git reset --hard` sur Internet. Cette commande **efface** du travail sans retour possible. Évite-la tant que tu ne la maîtrises pas. + +### 6. Le fichier .gitignore + +Certains fichiers ne doivent **jamais** entrer dans Git : + +- le dossier `.venv/` (énorme, spécifique à ta machine, se recrée avec `requirements.txt`) ; +- les caches `__pycache__/` et fichiers `*.pyc` (générés automatiquement par Python) ; +- les secrets : mots de passe, clés d'API, fichier `.env` ; +- les fichiers propres à ton système ou ton éditeur (`.DS_Store`, `Thumbs.db`, `.vscode/`…). + +On les liste dans un fichier texte nommé **exactement** `.gitignore`, à la racine du projet. Voici un bon point de départ pour Python : + +```gitignore +# Environnements virtuels +.venv/ +venv/ +env/ + +# Fichiers compilés de Python +__pycache__/ +*.py[cod] + +# Tests et outils +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Distribution / packaging +build/ +dist/ +*.egg-info/ + +# Secrets et réglages locaux +.env + +# Éditeurs et systèmes d'exploitation +.vscode/ +.idea/ +.DS_Store +Thumbs.db +``` + +Syntaxe : + +- `dossier/` ignore un dossier entier ; +- `*.log` ignore tous les fichiers finissant par `.log` ; +- `# ...` est un commentaire ; +- `!important.log` crée une exception (à ne pas ignorer). + +> 💡 **Crée le .gitignore AVANT ton premier commit.** GitHub propose aussi un modèle « Python » tout prêt quand tu crées un dépôt. + +Pour vérifier pourquoi un fichier est ignoré : + +```bash +git check-ignore -v .venv/pyvenv.cfg +``` + +**Si tu as déjà commité un fichier par erreur**, l'ajouter au `.gitignore` ne suffit pas : Git continue de le suivre. Il faut le retirer du suivi (sans le supprimer de ton disque) : + +```bash +git rm -r --cached .venv +echo ".venv/" >> .gitignore +git add .gitignore +git commit -m "Retire .venv du suivi Git" +``` + +> 🔐 **Secret commité = secret compromis.** Si tu as publié une clé d'API ou un mot de passe sur GitHub, le retirer dans un nouveau commit ne suffit pas : il reste dans l'historique. **Change immédiatement** ce mot de passe / révoque cette clé. + +### 7. Les branches + +Une **branche** est une ligne de développement parallèle. Analogie : tu écris un roman, et tu veux tester une fin alternative. Plutôt que de raturer ton manuscrit, tu fais une photocopie et tu écris la nouvelle fin dessus. Si elle te plaît, tu l'intègres au manuscrit ; sinon, tu jettes la photocopie. + +```text +main : A ── B ──────────── E (fusion) + \ / +nouvelle-fin : C ──── D ── +``` + +#### 7.1 Créer et changer de branche + +```bash +git branch # liste les branches (* = branche actuelle) +git switch -c ajout-salutation # crée la branche ET s'y place +git switch main # revient sur main +git branch -d ajout-salutation # supprime une branche déjà fusionnée +``` + +> 💡 Dans de vieux tutoriels, tu verras `git checkout -b nom` à la place de `git switch -c nom` : c'est équivalent. `switch` est plus récent et plus clair. + +Quand tu changes de branche, **les fichiers de ton dossier changent réellement** pour refléter l'état de cette branche. C'est normal ! Pense à committer (ou annuler) tes modifications avant de changer de branche. + +#### 7.2 Fusionner : `git merge` + +On se place sur la branche qui **reçoit** les modifications, puis on fusionne : + +```bash +git switch main +git merge ajout-salutation +``` + +Si `main` n'a pas bougé depuis la création de la branche, Git avance simplement le pointeur (*fast-forward*) : + +```text +Updating b83e225..4f5b154 +Fast-forward + main.py | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) +``` + +Si les deux branches ont avancé, Git crée un **commit de fusion** qui réunit les deux histoires. + +#### 7.3 Résoudre un conflit + +Un **conflit** survient quand les deux branches ont modifié **la même ligne** différemment. Git ne peut pas deviner laquelle garder, il te demande d'arbitrer : + +```text +Auto-merging main.py +CONFLICT (content): Merge conflict in main.py +Automatic merge failed; fix conflicts and then commit the result. +``` + +Le fichier contient alors des marqueurs : + +```text +nom = input("Nom ? ") +<<<<<<< HEAD +print(f"Bienvenue {nom}") +======= +print(f"Salut {nom} !") +>>>>>>> couleur +``` + +- entre `<<<<<<< HEAD` et `=======` : la version de ta branche actuelle (`main`) ; +- entre `=======` et `>>>>>>> couleur` : la version de la branche fusionnée. + +**Pour résoudre** : + +1. Ouvre le fichier, choisis la bonne version (ou combine-les), **supprime les trois lignes de marqueurs**. VS Code affiche des boutons « Accept Current / Accept Incoming / Accept Both » très pratiques. +2. Résultat, par exemple : + + ```python + nom = input("Nom ? ") + print(f"Bienvenue {nom} !") + ``` + +3. Termine la fusion : + + ```bash + git add main.py + git commit -m "Fusionne la branche couleur" + ``` + +Pas de panique : un conflit est une situation **normale** et sans danger. Si tu veux tout annuler et revenir avant la fusion : `git merge --abort`. + +### 8. GitHub : publier et collaborer + +#### 8.1 Créer un compte et un dépôt + +1. Crée un compte gratuit sur [github.com](https://github.com). +2. Clique sur **+** (en haut à droite) → **New repository**. +3. Donne un nom (ex. `gestion-taches`), une courte description, choisis *Public* ou *Private*. +4. **Si ton projet existe déjà en local**, ne coche **rien** (pas de README, pas de .gitignore, pas de licence) : le dépôt doit être vide pour éviter des conflits au premier envoi. +5. Clique sur **Create repository**. GitHub affiche l'adresse du dépôt, par exemple `https://github.com/camille-martin/gestion-taches.git`. + +#### 8.2 S'authentifier + +GitHub n'accepte plus ton mot de passe dans le terminal. Trois solutions, de la plus simple à la plus technique : + +- **GitHub CLI** : installe `gh` ([cli.github.com](https://cli.github.com)) puis lance `gh auth login` et laisse-toi guider. ✅ Recommandé pour débuter. +- **Git Credential Manager** : inclus dans Git for Windows ; une fenêtre de connexion s'ouvre au premier `push`. +- **Clé SSH** : plus technique, très pratique ensuite (voir la documentation GitHub « Connecting to GitHub with SSH »). + +#### 8.3 Relier ton dépôt local : `git remote` + +Un *remote* est un dépôt distant, avec un surnom. Par convention, le principal s'appelle `origin`. + +```bash +git remote add origin https://github.com/camille-martin/gestion-taches.git +git remote -v # vérifie +``` + +#### 8.4 Envoyer : `git push` + +```bash +git push -u origin main +``` + +`-u` (ou `--set-upstream`) mémorise le lien entre ta branche `main` locale et celle de GitHub. **La première fois seulement.** Ensuite, un simple `git push` suffit. + +Rafraîchis la page GitHub : ton code est en ligne ! 🚀 + +#### 8.5 Récupérer : `git pull` et `git clone` + +- **`git clone`** : télécharger un dépôt **pour la première fois** (sur un nouvel ordinateur, ou le projet de quelqu'un d'autre). + + ```bash + git clone https://github.com/camille-martin/gestion-taches.git + cd gestion-taches + python3 -m venv .venv # Windows : py -m venv .venv + source .venv/bin/activate # Windows : .venv\Scripts\Activate.ps1 + pip install -r requirements.txt + ``` + + Remarque : le `.venv` n'est pas dans le dépôt (grâce au `.gitignore`), donc on le recrée. C'est exactement pour ça que `requirements.txt` existe. + +- **`git pull`** : récupérer les **nouveaux commits** d'un dépôt que tu as déjà (par exemple ceux que tu as poussés depuis un autre ordinateur, ou ceux d'un collègue). + + ```bash + git pull + ``` + +Routine quotidienne quand tu travailles sur plusieurs machines : + +```text +git pull → coder → git add → git commit → git push +``` + +#### 8.6 Le README.md + +Le fichier `README.md` à la racine est **la vitrine** de ton projet : GitHub l'affiche automatiquement sous la liste des fichiers. Il est écrit en **Markdown** (le même format que ce cours). Un bon README répond à : *c'est quoi ? comment l'installer ? comment l'utiliser ?* + +````markdown +# Gestion de tâches + +Petit gestionnaire de tâches en ligne de commande, écrit en Python. + +## Fonctionnalités + +- Ajouter, lister et cocher des tâches +- Sauvegarde automatique dans un fichier JSON + +## Installation + +```bash +git clone https://github.com/camille-martin/gestion-taches.git +cd gestion-taches +python3 -m venv .venv +source .venv/bin/activate # Windows : .venv\Scripts\Activate.ps1 +pip install -r requirements.txt +``` + +## Utilisation + +```bash +python main.py +``` + +## Auteur + +Camille Martin — projet réalisé en apprenant Python. +```` + +#### 8.7 Pour aller plus loin sur GitHub + +- **Issues** : liste de bugs et d'idées pour ton projet. +- **Pull requests** : proposer des modifications (depuis une branche) et les faire relire avant de fusionner. C'est la base du travail en équipe et de la contribution aux projets open source. +- **Fork** : copier le dépôt de quelqu'un d'autre dans ton compte pour y contribuer. +- **Licence** : un fichier `LICENSE` (MIT, GPL…) indique ce que les autres ont le droit de faire avec ton code. + +### 9. Résumé des commandes + +| Besoin | Commande | +|---|---| +| Créer un venv | `python3 -m venv .venv` (Windows : `py -m venv .venv`) | +| Activer (macOS/Linux) | `source .venv/bin/activate` | +| Activer (Windows PowerShell) | `.venv\Scripts\Activate.ps1` | +| Désactiver | `deactivate` | +| Installer / désinstaller | `pip install x` / `pip uninstall x` | +| Lister | `pip list` / `pip freeze` | +| Sauver / restaurer les dépendances | `pip freeze > requirements.txt` / `pip install -r requirements.txt` | +| Nouveau dépôt Git | `git init` | +| État | `git status` | +| Préparer / enregistrer | `git add .` / `git commit -m "..."` | +| Historique / différences | `git log --oneline` / `git diff` | +| Branches | `git switch -c x` / `git switch main` / `git merge x` | +| GitHub | `git remote add origin URL` / `git push -u origin main` / `git pull` / `git clone URL` | + +--- + +## Pièges fréquents + +### 1. Oublier d'activer le venv + +**Symptôme** : tu as fait `pip install requests`, mais `import requests` donne `ModuleNotFoundError: No module named 'requests'`. Ou l'inverse : le paquet s'installe dans le Python global. + +**Correction** : vérifie que `(.venv)` apparaît au début de l'invite. Sinon, active le venv (`source .venv/bin/activate` ou `.venv\Scripts\Activate.ps1`). Dans VS Code, vérifie aussi l'interpréteur sélectionné (en bas à droite de la fenêtre) : il doit pointer vers `.venv`. + +### 2. Committer le dossier .venv + +**Symptôme** : `git status` affiche des milliers de fichiers, le `push` est interminable, et le dépôt GitHub contient un dossier `.venv` énorme qui ne fonctionne de toute façon pas sur un autre ordinateur. + +**Correction** : ajoute `.venv/` au `.gitignore`, puis retire-le du suivi : `git rm -r --cached .venv`, et committe. À l'avenir, crée le `.gitignore` avant le premier `git add`. + +### 3. Pas de .gitignore (ou mal nommé) + +**Symptôme** : `__pycache__/` et `.pyc` apparaissent dans chaque commit ; ou ton fichier s'appelle `gitignore.txt` / `.gitignore.txt` et n'a aucun effet. + +**Correction** : le fichier doit s'appeler **exactement** `.gitignore` (avec le point, sans extension), et être à la racine du projet. Sous Windows, active « Afficher les extensions de fichiers » dans l'explorateur pour vérifier qu'il n'y a pas de `.txt` caché. + +### 4. Lancer `git init` dans le mauvais dossier + +**Symptôme** : `git status` liste tout ton dossier personnel (Documents, Images…), ou tu as un dépôt dans un dépôt. + +**Correction** : vérifie où tu es avec `pwd` (macOS/Linux) ou `Get-Location` (PowerShell) **avant** `git init`. Pour annuler un `git init` malencontreux, supprime le dossier `.git` créé au mauvais endroit (et seulement celui-là !). + +### 5. `requirements.txt` oublié ou pas à jour + +**Symptôme** : le projet fonctionne chez toi, mais chez ton ami (ou après un `git clone`), on obtient `ModuleNotFoundError`. + +**Correction** : après chaque `pip install` d'un nouveau paquet, régénère `pip freeze > requirements.txt` et committe ce fichier. Teste de temps en temps : supprime `.venv`, recrée-le, `pip install -r requirements.txt`, et lance ton programme. + +### 6. Rester coincé dans `git log` ou dans l'éditeur de commit + +**Symptôme** : après `git log`, le terminal n'accepte plus de commande ; ou après `git commit` (sans `-m`), un écran étrange (Vim) s'ouvre. + +**Correction** : dans `git log`, appuie sur **`q`**. Dans Vim : tape ton message après avoir appuyé sur `i`, puis `Échap`, puis `:wq` et `Entrée` (ou `:q!` pour abandonner). Pour éviter ça, utilise toujours `git commit -m "message"`, ou configure VS Code comme éditeur : `git config --global core.editor "code --wait"`. + +### 7. Le premier `push` est refusé + +**Symptôme** : `! [rejected] main -> main (fetch first)` ou `failed to push some refs`. + +**Correction** : le dépôt GitHub contient des commits que tu n'as pas (souvent parce que tu as coché « Add a README » à la création). Récupère-les d'abord : `git pull origin main --allow-unrelated-histories`, résous un éventuel conflit, puis `git push`. Pour l'éviter : crée le dépôt GitHub **vide** quand ton projet existe déjà en local. + +### 8. Laisser des marqueurs de conflit dans le code + +**Symptôme** : `SyntaxError` sur une ligne `<<<<<<< HEAD` après une fusion. + +**Correction** : un conflit n'est résolu que lorsque **toutes** les lignes `<<<<<<<`, `=======` et `>>>>>>>` ont disparu. Cherche-les (`Ctrl+F` sur `<<<<<<<`), corrige, puis `git add` et `git commit`. + +--- + +## Exercices + +### Exercice 1 ⭐ — Premier venv + +1. Crée un dossier `test_venv` et ouvre un terminal dedans. +2. Crée un environnement virtuel `.venv` puis active-le. +3. Vérifie que tu es dans le venv avec une commande Python d'une ligne. +4. Désactive-le, relance la même commande : que constates-tu ? + +Écris la suite exacte de commandes pour **ton** système (et, si tu peux, pour l'autre). + +### Exercice 2 ⭐ — Suis-je dans un venv ? + +Écris un script `verifier_venv.py` qui affiche « ✅ Tu es dans un environnement virtuel » suivi du dossier du venv, ou « ⚠️ Tu utilises le Python global du système » suivi du chemin de l'interpréteur. + +*Indice* : dans un venv, `sys.prefix` (le dossier du Python en cours) est différent de `sys.base_prefix` (le dossier du Python d'origine). `sys.executable` donne le chemin de l'interpréteur. + +Lance-le avec et sans venv activé. + +### Exercice 3 ⭐ — La liste de courses + +Dans ton venv de l'exercice 1 : + +1. installe le paquet `rich` ; +2. affiche la liste des paquets installés ; +3. génère un `requirements.txt` ; +4. supprime le venv, recrée-le, et réinstalle tout à partir du `requirements.txt` ; +5. vérifie que ça marche avec `python -c "from rich import print; print('[bold green]Ça marche ![/]')"`. + +### Exercice 4 ⭐⭐ — Trier le bon grain de l'ivraie + +Voici le contenu d'un dossier de projet : + +```text +calculatrice/ +├── .venv/ +├── __pycache__/operations.cpython-312.pyc +├── .env (contient API_KEY=abc123) +├── .vscode/settings.json +├── operations.py +├── main.py +├── requirements.txt +├── README.md +├── notes_perso.txt +└── historique.log +``` + +1. Indique, pour chaque élément, s'il doit être **commité** ou **ignoré**, et pourquoi. +2. Écris le `.gitignore` correspondant (on décide que les fichiers `.log` et `notes_perso.txt` sont personnels). + +### Exercice 5 ⭐⭐ — Trois commits et un retour en arrière + +1. Crée un dossier `journal`, initialise un dépôt Git et un `.gitignore` Python. +2. Crée `journal.py` qui affiche « Mon journal ». Committe. +3. Ajoute une ligne qui affiche la date du jour (avec `datetime`). Avant de committer, affiche les différences. Committe. +4. Ajoute une ligne qui affiche « Bonne journée ! ». Committe. +5. Affiche l'historique sur une ligne par commit. +6. Modifie `journal.py` en y écrivant n'importe quoi, puis **annule** cette modification avec Git. +7. Affiche le contenu de `journal.py` tel qu'il était au tout premier commit, sans rien modifier. + +### Exercice 6 ⭐⭐ — Branches et conflit volontaire + +Dans le dépôt de l'exercice 5 : + +1. Crée une branche `anglais` et change « Bonne journée ! » en « Have a nice day! ». Committe. +2. Reviens sur `main` et change « Bonne journée ! » en « Belle journée à toi ! ». Committe. +3. Fusionne `anglais` dans `main` : un conflit apparaît. +4. Résous-le en gardant les deux phrases, sur deux lignes. Termine la fusion. +5. Affiche le graphe de l'historique, puis supprime la branche `anglais`. + +### Exercice 7 ⭐⭐⭐ — Analyseur de requirements.txt + +Écris un script `analyser_requirements.py` qui lit un fichier `requirements.txt` et affiche : + +- la liste des paquets **épinglés** (`nom==version`) avec leur version ; +- la liste des paquets **non épinglés** (sans version, ou avec `>=`, `~=`…), accompagnés d'un avertissement. + +Le script doit ignorer les lignes vides et les commentaires (ce qui suit un `#`, y compris en fin de ligne). Écris une fonction `analyser_ligne(ligne)` qui renvoie un tuple `(nom, version)` (avec `version = None` si non épinglé) ou `None` pour une ligne à ignorer. + +Teste avec : + +```text +# Dépendances du projet +requests==2.32.3 +rich>=13.0 + +flask # serveur web +pandas==2.2.2 +``` + +### Exercice 8 ⭐⭐⭐ — Générateur de projet + +Écris un script `nouveau_projet.py` qui, lancé avec `python nouveau_projet.py mon_projet` : + +1. crée le dossier `mon_projet` (et s'arrête avec un message d'erreur s'il existe déjà) ; +2. y écrit un `.gitignore` Python, un `README.md` (titre = nom du projet), un `requirements.txt` vide et un `main.py` minimal avec `if __name__ == "__main__":` ; +3. crée un venv `.venv` dans ce dossier ; +4. initialise un dépôt Git et fait un premier commit « Initialisation du projet ». + +*Indices* : `pathlib.Path` pour les fichiers ([chapitre 07](07-fichiers-csv-json.md)), `sys.argv` pour les arguments, et `subprocess.run([...], cwd=dossier, check=True)` pour lancer une commande dans un dossier donné. `sys.executable` est le chemin du Python en cours. + +--- + +## Mini-projet + +### 🚀 Publier un de tes projets sur GitHub + +Tu as écrit plusieurs programmes depuis le début du cours : un jeu de devinette, un gestionnaire de contacts, un carnet de notes en JSON ([chapitre 07](07-fichiers-csv-json.md)), des classes ([chapitre 10](10-poo-classes-objets.md))… Il est temps d'en choisir un et de lui donner une **vraie vie de projet** : versionné, documenté, publié. + +#### Cahier des charges + +Ton projet publié doit : + +1. avoir son propre dossier avec un **environnement virtuel** `.venv` ; +2. contenir un **`requirements.txt`** à jour (même s'il est vide parce que tu n'utilises que la bibliothèque standard — dans ce cas, écris-y un commentaire `# Aucune dépendance externe`) ; +3. contenir un **`.gitignore`** Python complet ; +4. contenir un **`README.md`** avec : titre, description, fonctionnalités, installation, utilisation, auteur ; +5. avoir **au moins 4 commits** avec des messages clairs (pas un seul commit géant) ; +6. avoir utilisé **au moins une branche** fusionnée dans `main` pour ajouter une fonctionnalité ou une amélioration ; +7. être **publié sur GitHub** ; +8. être **clonable et exécutable** : un `git clone` dans un autre dossier, suivi de l'installation décrite dans le README, doit permettre de lancer le programme. + +#### Étapes suggérées + +1. **Choisir et ranger** : copie ton projet dans un nouveau dossier (ex. `~/projets/carnet-notes`). Supprime les fichiers inutiles. +2. **Environnement** : crée et active le venv, installe les éventuelles dépendances, génère `requirements.txt`. +3. **Git** : crée le `.gitignore`, `git init`, vérifie avec `git status` que `.venv` n'apparaît pas, premier commit. +4. **README** : rédige-le, committe-le séparément. +5. **Branche** : crée une branche `amelioration`, ajoute une petite fonctionnalité (un message d'accueil, une option de menu, une validation de saisie…), committe, fusionne dans `main`. +6. **GitHub** : crée le dépôt vide, ajoute le remote, pousse. +7. **Test de vérité** : dans un autre dossier, `git clone`, recrée le venv, installe, lance. Si quelque chose manque, corrige, committe et pousse à nouveau. + +#### Pistes d'amélioration + +- Ajoute un fichier `LICENSE` (GitHub propose « Add file → Create new file » puis un modèle de licence MIT). +- Ajoute une capture d'écran ou un exemple de sortie du terminal dans le README. +- Crée deux ou trois *issues* sur GitHub pour noter tes idées d'améliorations, puis ferme-les avec un commit contenant `Closes #1` dans le message. +- Travaille depuis un deuxième ordinateur (ou un deuxième dossier) et entraîne-toi au cycle `pull` → modifier → `push`. +- Remplace `requirements.txt` par un `pyproject.toml` et essaie `uv`. + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +**macOS / Linux :** + +```bash +mkdir test_venv +cd test_venv +python3 -m venv .venv +source .venv/bin/activate +python -c "import sys; print(sys.prefix)" +# → /home/camille/test_venv/.venv (se termine par .venv) +deactivate +python3 -c "import sys; print(sys.prefix)" +# → /usr (le Python du système) +``` + +**Windows PowerShell :** + +```powershell +mkdir test_venv +cd test_venv +py -m venv .venv +.venv\Scripts\Activate.ps1 +python -c "import sys; print(sys.prefix)" +# → C:\Users\Camille\test_venv\.venv +deactivate +py -c "import sys; print(sys.prefix)" +# → C:\Users\Camille\AppData\Local\Programs\Python\Python313 +``` + +**Constat** : venv activé, `sys.prefix` pointe vers le dossier `.venv` ; une fois désactivé, il pointe vers l'installation globale de Python. C'est la preuve que l'activation change le Python utilisé. Note aussi que dans le venv, `python` suffit, alors qu'en dehors on écrit `python3` (macOS/Linux) ou `py` (Windows). + +
+ +
+Corrigé de l'exercice 2 + +```python +import sys + + +def est_dans_un_venv() -> bool: + """Renvoie True si le script tourne dans un environnement virtuel.""" + return sys.prefix != sys.base_prefix + + +if est_dans_un_venv(): + print("✅ Tu es dans un environnement virtuel.") + print(f" Dossier du venv : {sys.prefix}") +else: + print("⚠️ Tu utilises le Python global du système.") + print(f" Python utilisé : {sys.executable}") +``` + +Exécution sans venv puis avec : + +```text +$ python3 verifier_venv.py +⚠️ Tu utilises le Python global du système. + Python utilisé : /usr/bin/python3 + +$ source .venv/bin/activate +(.venv) $ python verifier_venv.py +✅ Tu es dans un environnement virtuel. + Dossier du venv : /home/camille/test_venv/.venv +``` + +**Explication** : quand Python démarre depuis un venv, il garde en mémoire deux informations : `sys.base_prefix` (l'installation d'origine qui a servi à créer le venv) et `sys.prefix` (le venv lui-même). Hors venv, les deux sont identiques. Comparer les deux est donc la méthode officielle pour détecter un venv. + +
+ +
+Corrigé de l'exercice 3 + +```bash +# (venv activé) +pip install rich +pip list +pip freeze > requirements.txt +cat requirements.txt # Windows PowerShell : Get-Content requirements.txt + +deactivate +rm -rf .venv # Windows : Remove-Item -Recurse -Force .venv +python3 -m venv .venv # Windows : py -m venv .venv +source .venv/bin/activate # Windows : .venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python -c "from rich import print; print('[bold green]Ça marche ![/]')" +``` + +`requirements.txt` contient `rich==...` **et** ses dépendances (`markdown-it-py`, `mdurl`, `pygments`…), car `pip freeze` liste *tout* ce qui est installé. Après la recréation, `pip install -r` réinstalle exactement les mêmes versions : le venv est bien jetable et reproductible. + +
+ +
+Corrigé de l'exercice 4 + +| Élément | Décision | Raison | +|---|---|---| +| `.venv/` | ❌ ignorer | lourd, spécifique à la machine, se recrée avec `requirements.txt` | +| `__pycache__/` | ❌ ignorer | généré automatiquement par Python | +| `.env` | ❌ ignorer | **contient un secret** (clé d'API) | +| `.vscode/` | ❌ ignorer | réglages personnels de l'éditeur | +| `operations.py`, `main.py` | ✅ commiter | c'est le code du projet | +| `requirements.txt` | ✅ commiter | indispensable pour réinstaller les dépendances | +| `README.md` | ✅ commiter | documentation du projet | +| `notes_perso.txt` | ❌ ignorer | personnel (décision de l'énoncé) | +| `historique.log` | ❌ ignorer | fichier généré à l'exécution | + +```gitignore +# Environnement virtuel +.venv/ + +# Fichiers compilés +__pycache__/ +*.pyc + +# Secrets +.env + +# Éditeur +.vscode/ + +# Fichiers personnels et générés +notes_perso.txt +*.log +``` + +Pour vérifier : `git status` ne doit plus montrer que `operations.py`, `main.py`, `requirements.txt`, `README.md` et `.gitignore`. + +**Bonne pratique** : fournis un fichier `.env.example` (commité) qui contient `API_KEY=ta_cle_ici`, pour indiquer aux autres quelles variables définir sans divulguer les tiennes. + +
+ +
+Corrigé de l'exercice 5 + +```bash +mkdir journal && cd journal +git init +printf '.venv/\n__pycache__/\n*.pyc\n.env\n' > .gitignore # ou crée-le dans VS Code +``` + +`journal.py`, version 1 : + +```python +print("Mon journal") +``` + +```bash +git add . +git commit -m "Crée le script du journal" +``` + +Version 2 : + +```python +from datetime import date + +print("Mon journal") +print(f"Nous sommes le {date.today():%d/%m/%Y}") +``` + +```bash +git diff # montre les lignes ajoutées en + +git commit -am "Affiche la date du jour" +``` + +Version 3 : ajoute `print("Bonne journée !")`, puis : + +```bash +git commit -am "Ajoute un message de fin" +git log --oneline +``` + +```text +c3d4e5f Ajoute un message de fin +b2c3d4e Affiche la date du jour +a1b2c3d Crée le script du journal +``` + +Étape 6, annuler une modification non commitée : + +```bash +git status # journal.py apparaît en "modified" +git restore journal.py # retour à la version du dernier commit +git status # "nothing to commit, working tree clean" +``` + +Étape 7, consulter le premier commit **sans rien modifier** (remplace `a1b2c3d` par ton identifiant réel, lu dans `git log --oneline`) : + +```bash +git show a1b2c3d:journal.py +``` + +`git show identifiant:chemin` affiche le contenu d'un fichier à un instant donné de l'historique, sans toucher à ton dossier de travail. + +
+ +
+Corrigé de l'exercice 6 + +```bash +git switch -c anglais +# dans journal.py : "Bonne journée !" → "Have a nice day!" +git commit -am "Traduit le message de fin en anglais" + +git switch main +# dans journal.py : "Bonne journée !" → "Belle journée à toi !" +git commit -am "Reformule le message de fin" + +git merge anglais +``` + +```text +Auto-merging journal.py +CONFLICT (content): Merge conflict in journal.py +Automatic merge failed; fix conflicts and then commit the result. +``` + +Le fichier contient : + +```text +<<<<<<< HEAD +print("Belle journée à toi !") +======= +print("Have a nice day!") +>>>>>>> anglais +``` + +On garde les deux et on **supprime les marqueurs** : + +```python +print("Belle journée à toi !") +print("Have a nice day!") +``` + +```bash +python journal.py # on vérifie que le script fonctionne +git add journal.py +git commit -m "Fusionne la branche anglais" +git log --oneline --graph +git branch -d anglais +``` + +```text +* e5f6a7b Fusionne la branche anglais +|\ +| * d4e5f6a Traduit le message de fin en anglais +* | c4d5e6f Reformule le message de fin +|/ +* c3d4e5f Ajoute un message de fin +... +``` + +Le graphe montre bien les deux lignes parallèles qui se rejoignent dans le commit de fusion. `git branch -d` accepte de supprimer `anglais` car elle est entièrement fusionnée. + +
+ +
+Corrigé de l'exercice 7 + +```python +from pathlib import Path + + +def analyser_ligne(ligne: str) -> tuple[str, str | None] | None: + """Analyse une ligne de requirements.txt. + + Renvoie (nom, version) ou None si la ligne est vide ou un commentaire. + version vaut None si le paquet n'est pas épinglé avec ==. + """ + ligne = ligne.split("#", 1)[0].strip() # retire les commentaires + if not ligne: + return None + if "==" in ligne: + nom, version = ligne.split("==", 1) + return nom.strip(), version.strip() + # >=, <=, ~= ou rien du tout : on récupère juste le nom + for separateur in (">=", "<=", "~=", ">", "<"): + if separateur in ligne: + return ligne.split(separateur, 1)[0].strip(), None + return ligne, None + + +def analyser_fichier(chemin: Path) -> None: + epingles = [] + non_epingles = [] + for ligne in chemin.read_text(encoding="utf-8").splitlines(): + resultat = analyser_ligne(ligne) + if resultat is None: + continue + nom, version = resultat + if version is None: + non_epingles.append(nom) + else: + epingles.append(f"{nom} ({version})") + + print(f"Paquets épinglés ({len(epingles)}) :") + for paquet in epingles: + print(f" - {paquet}") + print(f"Paquets NON épinglés ({len(non_epingles)}) :") + for nom in non_epingles: + print(f" - {nom} ⚠️ version libre") + + +if __name__ == "__main__": + analyser_fichier(Path("requirements.txt")) +``` + +Sortie avec le fichier de l'énoncé : + +```text +Paquets épinglés (2) : + - requests (2.32.3) + - pandas (2.2.2) +Paquets NON épinglés (2) : + - rich ⚠️ version libre + - flask ⚠️ version libre +``` + +**Points clés** : + +- `split("#", 1)[0]` garde ce qui est **avant** le premier `#` : ça élimine à la fois les lignes de commentaire et les commentaires de fin de ligne. +- Pour les autres opérateurs (`>=`, `~=`…), le nom du paquet est simplement tout ce qui précède l'opérateur : `"rich>=13.0".split(">=", 1)[0]` donne `"rich"`. +- La fonction `analyser_ligne` est **pure** (pas d'affichage, pas de fichier) : elle sera très facile à tester avec pytest au [chapitre 13](13-tests-pytest-bonnes-pratiques.md). + +
+ +
+Corrigé de l'exercice 8 + +```python +"""Crée le squelette d'un nouveau projet Python prêt pour Git. + +Usage : python nouveau_projet.py nom_du_projet +""" +import subprocess +import sys +from pathlib import Path + +GITIGNORE = """\ +# Environnements virtuels +.venv/ +venv/ + +# Fichiers compilés de Python +__pycache__/ +*.pyc + +# Secrets et réglages locaux +.env + +# Éditeurs et système +.vscode/ +.idea/ +.DS_Store +Thumbs.db +""" + + +def creer_projet(nom: str) -> Path: + dossier = Path(nom) + if dossier.exists(): + raise SystemExit(f"Erreur : le dossier « {nom} » existe déjà.") + + dossier.mkdir() + (dossier / ".gitignore").write_text(GITIGNORE, encoding="utf-8") + (dossier / "README.md").write_text( + f"# {nom}\n\nDescription du projet.\n", encoding="utf-8" + ) + (dossier / "requirements.txt").write_text("", encoding="utf-8") + (dossier / "main.py").write_text( + 'def main():\n print("Bonjour !")\n\n\n' + 'if __name__ == "__main__":\n main()\n', + encoding="utf-8", + ) + + # sys.executable = le Python qui exécute ce script + subprocess.run([sys.executable, "-m", "venv", ".venv"], cwd=dossier, check=True) + subprocess.run(["git", "init"], cwd=dossier, check=True) + subprocess.run(["git", "add", "."], cwd=dossier, check=True) + subprocess.run( + ["git", "commit", "-m", "Initialisation du projet"], cwd=dossier, check=True + ) + return dossier + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage : python nouveau_projet.py nom_du_projet") + sys.exit(1) + chemin = creer_projet(sys.argv[1]) + print(f"Projet créé dans {chemin.resolve()}") +``` + +Vérification : + +```bash +python3 nouveau_projet.py demo_projet +cd demo_projet +git log --oneline # → 1 commit "Initialisation du projet" +git ls-files # → .gitignore, README.md, main.py, requirements.txt +``` + +`.venv` existe bien dans le dossier mais n'est **pas** dans `git ls-files` : le `.gitignore` a été écrit **avant** le `git add .`, l'ordre des opérations est essentiel. + +**Remarques** : + +- `subprocess.run(..., check=True)` lève une exception si la commande échoue (par exemple si Git n'est pas installé) : le programme ne continue pas en silence sur une erreur. +- `cwd=dossier` exécute la commande *dans* le nouveau dossier, sans changer le dossier courant du script. +- `raise SystemExit("message")` affiche le message et quitte le programme avec un code d'erreur. +- Si Git n'a pas ton nom et ton e-mail, le commit échoue : pense au `git config --global` de la section 5.1. + +
+ +
+Corrigé du mini-projet + +Exemple avec un carnet de notes (`carnet-notes`). Les commandes sont pour macOS/Linux ; les variantes Windows sont en commentaire. + +**1–2. Dossier et environnement** + +```bash +mkdir -p ~/projets/carnet-notes && cd ~/projets/carnet-notes +# (copie ici tes fichiers : main.py, carnet.py…) +python3 -m venv .venv # Windows : py -m venv .venv +source .venv/bin/activate # Windows : .venv\Scripts\Activate.ps1 +# Si le projet n'a aucune dépendance externe : +echo "# Aucune dépendance externe" > requirements.txt +# Sinon : pip install ... puis pip freeze > requirements.txt +``` + +**3. Git et premier commit** + +Crée le `.gitignore` de la section 6, puis : + +```bash +git init +git status # .venv NE doit PAS apparaître +git add . +git commit -m "Version initiale du carnet de notes" +``` + +**4. README** + +Écris `README.md` en suivant le modèle de la section 8.6, puis : + +```bash +git add README.md +git commit -m "Ajoute le README" +``` + +**5. Branche d'amélioration** + +```bash +git switch -c validation-saisie +# … code : refuser une note hors de [0, 20] avec un message clair … +python main.py # on teste ! +git commit -am "Valide les notes saisies entre 0 et 20" +# … une deuxième petite amélioration … +git commit -am "Affiche la moyenne avec deux décimales" +git switch main +git merge validation-saisie +git branch -d validation-saisie +git log --oneline --graph # au moins 4 commits +``` + +**6. Publication** + +Sur GitHub : *New repository* → nom `carnet-notes` → **ne rien cocher** → *Create*. + +```bash +git remote add origin https://github.com/TON-PSEUDO/carnet-notes.git +git push -u origin main +``` + +(Si l'authentification échoue : `gh auth login`, puis relance le push.) + +**7. Test de vérité** + +```bash +cd /tmp # Windows : cd $env:TEMP +git clone https://github.com/TON-PSEUDO/carnet-notes.git +cd carnet-notes +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +Si le programme démarre, ton projet est réellement **portable**. 🎉 + +**Grille de vérification** + +- [ ] `.venv` absent du dépôt GitHub +- [ ] `__pycache__` absent du dépôt GitHub +- [ ] README lisible sur la page d'accueil du dépôt +- [ ] au moins 4 commits aux messages clairs +- [ ] le graphe montre une branche fusionnée (ou ses commits) +- [ ] le clone fonctionne en suivant uniquement le README + +**Erreurs typiques rencontrées ici** : avoir créé le dépôt GitHub avec un README (→ voir piège 7), oublier d'ajouter un fichier `.py` avant le commit (→ le clone plante avec `ModuleNotFoundError` sur ton propre module : `git status` aurait montré le fichier en *untracked*). + +
+ +--- + +## Récapitulatif + +**Points clés à retenir :** + +- Un **venv** est un dossier `.venv` qui isole les bibliothèques d'un projet. Un projet = un venv. +- Création : `python3 -m venv .venv` (Windows : `py -m venv .venv`). Activation : `source .venv/bin/activate` (macOS/Linux) ou `.venv\Scripts\Activate.ps1` (Windows). Sortie : `deactivate`. +- **pip** installe depuis PyPI : `install`, `uninstall`, `list`, `show`, `freeze`. +- **requirements.txt** liste les dépendances : `pip freeze > requirements.txt` pour l'écrire, `pip install -r requirements.txt` pour tout réinstaller. +- **pyproject.toml** est le format de configuration moderne ; **uv** est un outil rapide qui regroupe venv + pip. +- **Git** enregistre des photos (commits) du projet : modifier → `git add` → `git commit -m`. `git status` est ta boussole. +- `git log --oneline`, `git diff`, `git restore` permettent de consulter et d'annuler. +- Le **.gitignore** exclut `.venv/`, `__pycache__/`, `.env`… et se crée **avant** le premier commit. +- Les **branches** (`git switch -c`, `git merge`) permettent d'expérimenter sans risque ; un conflit se résout à la main, puis `add` + `commit`. +- **GitHub** héberge tes dépôts : `git remote add origin`, `git push -u origin main`, `git pull`, `git clone`. Le **README.md** est la vitrine du projet. + +**Checklist :** + +- [ ] Je sais créer, activer (sur mon système) et désactiver un venv. +- [ ] Je vérifie la présence de `(.venv)` avant chaque `pip install`. +- [ ] Je sais générer et utiliser un `requirements.txt`. +- [ ] J'ai configuré mon nom et mon e-mail dans Git. +- [ ] Je connais le cycle `status` → `add` → `commit` et j'écris des messages clairs. +- [ ] J'ai un `.gitignore` Python dans chacun de mes projets. +- [ ] Je sais créer une branche, la fusionner et résoudre un conflit simple. +- [ ] J'ai publié au moins un projet sur GitHub, avec un README. +- [ ] Je sais cloner un projet et le faire fonctionner grâce à son README. + +--- + +[← Chapitre précédent](11-poo-avancee-heritage-typage.md) | [Sommaire](README.md) | [Chapitre suivant →](13-tests-pytest-bonnes-pratiques.md) diff --git a/docs/presentations/Python/13-tests-pytest-bonnes-pratiques.md b/docs/presentations/Python/13-tests-pytest-bonnes-pratiques.md new file mode 100644 index 0000000..6d58258 --- /dev/null +++ b/docs/presentations/Python/13-tests-pytest-bonnes-pratiques.md @@ -0,0 +1,1956 @@ +# Chapitre 13 — Tests avec pytest et bonnes pratiques + +> **⏱️ Durée estimée** : 5 à 7 heures (cours + exercices + mini-projet) +> +> **📋 Prérequis** : avoir terminé le [Chapitre 12 — Outils : venv, pip et Git](12-outils-venv-pip-git.md) (savoir créer un venv et installer un paquet), être à l'aise avec les fonctions ([Chapitre 05](05-fonctions.md)), les exceptions ([Chapitre 08](08-erreurs-et-debogage.md)) et les classes ([Chapitre 10](10-poo-classes-objets.md)). +> +> **🎯 Objectifs** — À la fin de ce chapitre, tu sauras… +> +> - écrire et lancer des **tests automatiques** avec **pytest**, et lire un rapport d'échec ; +> - utiliser les outils clés de pytest : `assert`, `pytest.approx`, **fixtures**, `@pytest.mark.parametrize`, `pytest.raises`, `tmp_path` ; +> - appliquer la démarche **TDD** (écrire le test avant le code) sur un petit problème ; +> - respecter les règles principales de **PEP 8** et documenter ton code avec des **docstrings** ; +> - vérifier et formater automatiquement ton code avec **ruff** ; +> - organiser un projet professionnel : `src/`, `tests/`, `README.md`, `pyproject.toml`. + +--- + +## Cours + +Jusqu'à présent, comment vérifiais-tu que ton code marchait ? Probablement en le lançant, en tapant quelques valeurs, et en regardant si le résultat avait l'air juste. Ça fonctionne… jusqu'au jour où tu modifies une fonction pour ajouter une fonctionnalité, et tu casses sans le voir quelque chose qui marchait avant. + +Ce chapitre t'apprend à **automatiser cette vérification** et à écrire du code **propre**, lisible par les autres (et par toi dans six mois). + +### 1. Pourquoi tester automatiquement ? + +Imagine un pilote d'avion. Avant chaque décollage, il suit une **checklist** : volets, carburant, instruments… Il ne se dit pas « hier tout marchait, ça ira ». Il vérifie, systématiquement, parce que c'est rapide et qu'une erreur coûterait cher. + +Un **test automatique** est une checklist pour ton code : une petite fonction qui appelle ton code avec des données connues et **vérifie** que le résultat est celui attendu. Une fois écrit, il se relance en une seconde, autant de fois que tu veux. + +Les bénéfices : + +- 🛡️ **Filet de sécurité** : tu modifies ton code sans peur ; si tu casses quelque chose, un test devient rouge immédiatement. +- 🐛 **Bugs trouvés tôt** : en pensant aux cas de test (liste vide ? nombre négatif ?), tu découvres des bugs avant tes utilisateurs. +- 📖 **Documentation vivante** : un test montre concrètement comment utiliser une fonction. +- 🧱 **Meilleure conception** : un code facile à tester est généralement un code bien découpé. + +> 💡 Il existe plusieurs types de tests. Dans ce chapitre, on écrit surtout des **tests unitaires** : ils testent une petite unité (une fonction, une méthode) isolément. + +### 2. Premier test avec `assert` + +Tu connais peut-être déjà le mot-clé `assert` : il vérifie qu'une condition est vraie, et lève une `AssertionError` sinon. + +```python +assert 2 + 2 == 4 # rien ne se passe : c'est vrai +assert 2 + 2 == 5 # AssertionError +``` + +Un test, c'est donc simplement une fonction qui contient des `assert`. Prenons un fichier `calculs.py` : + +```python +def additionner(a, b): + return a + b + + +def moyenne(notes): + """Renvoie la moyenne d'une liste de nombres.""" + if not notes: + raise ValueError("La liste de notes est vide.") + return sum(notes) / len(notes) + + +def est_pair(n): + return n % 2 == 0 +``` + +### 3. Installer et lancer pytest + +**pytest** est l'outil de test le plus utilisé en Python. Il trouve tes tests tout seul, les exécute, et affiche un rapport clair. Installe-le **dans ton venv** : + +```bash +# venv activé : (.venv) doit apparaître dans l'invite +pip install pytest +``` + +#### 3.1 Les conventions de nommage (importantes !) + +pytest découvre les tests **grâce à leur nom** : + +- les fichiers de test s'appellent `test_quelquechose.py` (ou `quelquechose_test.py`) ; +- les fonctions de test commencent par `test_` ; +- (si tu regroupes des tests dans une classe, elle commence par `Test` et n'a pas de `__init__`). + +Une fonction nommée `verifier_addition()` ne sera **jamais** exécutée par pytest, même si elle est dans un fichier de test. + +#### 3.2 Notre premier fichier de test + +À côté de `calculs.py`, crée `test_calculs.py` : + +```python +from calculs import additionner, est_pair, moyenne + + +def test_additionner_deux_positifs(): + assert additionner(2, 3) == 5 + + +def test_additionner_negatif(): + assert additionner(-1, 1) == 0 + + +def test_moyenne_simple(): + assert moyenne([10, 12, 14]) == 12 + + +def test_est_pair(): + assert est_pair(4) + assert not est_pair(7) +``` + +Donne à tes tests des **noms descriptifs** : quand un test échoue, son nom est la première chose que tu lis. `test_moyenne_liste_vide_leve_une_erreur` vaut mieux que `test_3`. + +#### 3.3 Lancer les tests + +Dans le terminal, depuis le dossier du projet : + +```bash +pytest +``` + +```text +============================= test session starts ============================== +collected 4 items + +test_calculs.py .... [100%] + +============================== 4 passed in 0.01s =============================== +``` + +Chaque point `.` est un test réussi. Options utiles : + +```bash +pytest -v # verbeux : affiche le nom de chaque test +pytest test_calculs.py # un seul fichier +pytest test_calculs.py::test_est_pair # un seul test +pytest -k moyenne # seulement les tests dont le nom contient "moyenne" +pytest -x # s'arrête au premier échec +pytest --lf # relance seulement les tests qui ont échoué la dernière fois +``` + +> 💡 Si la commande `pytest` n'est pas reconnue (fréquent sous Windows), utilise `python -m pytest`. C'est équivalent, et ça garantit que tu utilises le pytest du venv. + +#### 3.4 Lire un échec + +Ajoutons volontairement un test faux : + +```python +def test_moyenne_fausse(): + assert moyenne([10, 20]) == 16 +``` + +```text +=================================== FAILURES =================================== +_____________________________ test_moyenne_fausse ______________________________ + + def test_moyenne_fausse(): +> assert moyenne([10, 20]) == 16 +E assert 15.0 == 16 +E + where 15.0 = moyenne([10, 20]) + +test_echec.py:5: AssertionError +=========================== short test summary info ============================ +FAILED test_echec.py::test_moyenne_fausse - assert 15.0 == 16 +============================== 1 failed in 0.02s =============================== +``` + +C'est là que pytest brille : il ne dit pas juste « faux », il montre **la ligne** (`>`), **les valeurs** comparées (`15.0 == 16`) et **d'où vient** la valeur (`where 15.0 = moyenne([10, 20])`). Ici, c'est le test qui est faux (la moyenne de 10 et 20 est bien 15) ; parfois ce sera le code. À toi d'enquêter ! + +> ⚠️ Un test qui échoue n'est pas un échec pour toi : c'est un **renseignement**. Le vrai danger, c'est un bug que personne ne détecte. + +#### 3.5 Les nombres décimaux : `pytest.approx` + +Tu te souviens que `0.1 + 0.2 == 0.3` vaut `False` en Python, à cause de la représentation des nombres à virgule ([chapitre 01](01-variables-types-operateurs.md)) ? Pour comparer des `float`, utilise `pytest.approx` qui tolère une minuscule différence : + +```python +import pytest + + +def test_moyenne_avec_decimales(): + assert moyenne([0.1, 0.2]) == pytest.approx(0.15) +``` + +### 4. Tester les erreurs : `pytest.raises` + +Une bonne fonction refuse les données invalides en levant une exception ([chapitre 08](08-erreurs-et-debogage.md)). Il faut **tester ça aussi** ! `pytest.raises` s'utilise avec `with` : + +```python +import pytest + + +def test_moyenne_liste_vide_leve_une_erreur(): + with pytest.raises(ValueError): + moyenne([]) +``` + +Lecture : « dans ce bloc, je m'attends à ce qu'une `ValueError` soit levée ». Le test **réussit** si l'exception est levée, et **échoue** si elle ne l'est pas (ou si c'est une autre exception). + +Tu peux aussi vérifier le message, avec `match` (qui cherche un motif dans le texte de l'erreur) : + +```python +def test_moyenne_liste_vide_message(): + with pytest.raises(ValueError, match="vide"): + moyenne([]) +``` + +### 5. Tester beaucoup de cas : `@pytest.mark.parametrize` + +Tester `est_pair` avec 5 valeurs demanderait 5 fonctions presque identiques. Plutôt que de copier-coller, on **paramètre** le test : + +```python +import pytest + + +@pytest.mark.parametrize( + "nombre, attendu", + [ + (0, True), + (1, False), + (2, True), + (-3, False), + (100, True), + ], +) +def test_est_pair_parametre(nombre, attendu): + assert est_pair(nombre) == attendu +``` + +- Le premier argument est une chaîne avec les **noms des paramètres**, séparés par des virgules. +- Le second est une **liste de tuples** : chaque tuple est un cas de test. +- pytest exécute la fonction une fois par tuple, et les compte comme des tests séparés : + +```text +test_calculs.py::test_est_pair_parametre[0-True] PASSED +test_calculs.py::test_est_pair_parametre[1-False] PASSED +test_calculs.py::test_est_pair_parametre[2-True] PASSED +test_calculs.py::test_est_pair_parametre[-3-False] PASSED +test_calculs.py::test_est_pair_parametre[100-True] PASSED +``` + +Si un seul cas échoue, tu sais exactement lequel. Et ajouter un cas = ajouter une ligne. 🎯 + +> 💡 **Quels cas tester ?** Pense toujours à : un cas « normal », les **limites** (0, 1, liste vide, chaîne vide, valeur pile au seuil), les valeurs **négatives**, et les **entrées invalides**. C'est souvent aux bords que se cachent les bugs. + +### 6. Préparer des données : les fixtures + +Quand plusieurs tests ont besoin du même objet de départ (un panier rempli, un compte bancaire, un fichier…), on évite de le recréer à la main dans chaque test. On écrit une **fixture** : une fonction décorée par `@pytest.fixture` qui **fabrique** cet objet. + +Analogie : au restaurant, la *mise en place* est préparée avant le service ; chaque plat pioche dedans. Une fixture, c'est la mise en place de tes tests. + +Prenons une classe `Panier` (`panier.py`) : + +```python +class Panier: + def __init__(self): + self.articles = {} # nom -> (prix_unitaire, quantite) + + def ajouter(self, nom, prix, quantite=1): + if prix < 0 or quantite <= 0: + raise ValueError("Prix ou quantité invalide.") + _, deja = self.articles.get(nom, (prix, 0)) + self.articles[nom] = (prix, deja + quantite) + + def total(self): + return sum(prix * qte for prix, qte in self.articles.values()) + + def nombre_articles(self): + return sum(qte for _, qte in self.articles.values()) +``` + +Et ses tests (`test_panier.py`) : + +```python +import pytest + +from panier import Panier + + +@pytest.fixture +def panier_vide(): + return Panier() + + +@pytest.fixture +def panier_rempli(): + panier = Panier() + panier.ajouter("pomme", 0.5, 4) + panier.ajouter("pain", 1.2) + return panier + + +def test_panier_vide_total_nul(panier_vide): + assert panier_vide.total() == 0 + + +def test_total(panier_rempli): + assert panier_rempli.total() == pytest.approx(3.2) + + +def test_nombre_articles(panier_rempli): + assert panier_rempli.nombre_articles() == 5 + + +def test_ajouter_meme_article_cumule(panier_rempli): + panier_rempli.ajouter("pomme", 0.5, 2) + assert panier_rempli.nombre_articles() == 7 + + +def test_prix_negatif_refuse(panier_vide): + with pytest.raises(ValueError): + panier_vide.ajouter("bug", -1) +``` + +La magie : il suffit de mettre **le nom de la fixture en paramètre** du test. pytest voit `panier_rempli`, appelle la fixture du même nom, et passe le résultat au test. + +Point essentiel : **chaque test reçoit un objet tout neuf**. Dans `test_ajouter_meme_article_cumule`, on modifie le panier ; ça n'a aucun effet sur `test_nombre_articles`. Les tests sont **indépendants**, ce qui est indispensable : un test ne doit jamais dépendre de l'ordre d'exécution ou d'un autre test. + +> 💡 Pour partager des fixtures entre plusieurs fichiers de test, place-les dans un fichier spécial nommé `conftest.py` dans le dossier des tests : pytest le charge automatiquement, sans `import`. + +#### 6.1 Une fixture intégrée très utile : `tmp_path` + +pytest fournit des fixtures toutes prêtes. La plus utile pour toi : **`tmp_path`**, un dossier temporaire (un objet `pathlib.Path`, voir [chapitre 07](07-fichiers-csv-json.md)) **différent pour chaque test**, et nettoyé automatiquement. Parfait pour tester du code qui lit ou écrit des fichiers sans polluer ton disque. + +```python +# sauvegarde.py +import json +from pathlib import Path + + +def sauvegarder(donnees, chemin): + Path(chemin).write_text(json.dumps(donnees, ensure_ascii=False), encoding="utf-8") + + +def charger(chemin): + chemin = Path(chemin) + if not chemin.exists(): + return {} + return json.loads(chemin.read_text(encoding="utf-8")) +``` + +```python +# test_sauvegarde.py +from sauvegarde import charger, sauvegarder + + +def test_aller_retour(tmp_path): + fichier = tmp_path / "donnees.json" + sauvegarder({"nom": "Zoé", "age": 12}, fichier) + assert charger(fichier) == {"nom": "Zoé", "age": 12} + + +def test_fichier_absent(tmp_path): + assert charger(tmp_path / "inexistant.json") == {} +``` + +### 7. Structure d'un bon test : Arrange, Act, Assert + +Un test lisible suit souvent trois temps, comme une recette : + +1. **Arrange** (préparer) : créer les données nécessaires (souvent via une fixture) ; +2. **Act** (agir) : appeler la fonction testée ; +3. **Assert** (vérifier) : comparer le résultat à l'attendu. + +```python +def test_virement_debite_et_credite(): + # Arrange + compte_a = CompteBancaire("Alice", 100) + compte_b = CompteBancaire("Bob", 0) + # Act + compte_a.virer_vers(compte_b, 40) + # Assert + assert compte_a.solde == 60 + assert compte_b.solde == 40 +``` + +Règles d'or : + +- **un test = un comportement** (plusieurs `assert` sont OK s'ils vérifient la même idée) ; +- un test doit être **rapide** et **déterministe** (même résultat à chaque fois) ; +- teste **ce que fait** la fonction (son résultat), pas **comment** elle le fait. + +> 💡 Et le hasard ? Une fonction qui utilise `random` est difficile à tester. Astuce : vérifie des **propriétés** (« le dé donne toujours un nombre entre 1 et 6 », sur 1000 tirages), ou fixe la graine avec `random.seed(42)` pour obtenir toujours la même suite. + +### 8. TDD : écrire le test d'abord + +Le **TDD** (*Test-Driven Development*, développement piloté par les tests) inverse l'ordre habituel : on écrit le test **avant** le code. Le cycle tient en trois couleurs : + +```text + 🔴 ROUGE 🟢 VERT 🔵 REFACTOR + écrire un test → écrire le minimum → améliorer le code + qui échoue pour qu'il passe (les tests restent verts) + ↑___________________________________________| +``` + +Exemple avec le classique **FizzBuzz** : une fonction qui renvoie `"Fizz"` pour les multiples de 3, `"Buzz"` pour les multiples de 5, `"FizzBuzz"` pour les multiples des deux, et le nombre lui-même sinon. + +**Tour 1 — 🔴** On écrit d'abord le test le plus simple : + +```python +from fizzbuzz import fizzbuzz + + +def test_nombre_ordinaire(): + assert fizzbuzz(1) == "1" +``` + +`pytest` → échec (`ImportError` : la fonction n'existe pas encore). C'est **normal** et voulu. + +**Tour 1 — 🟢** Le minimum pour passer : + +```python +def fizzbuzz(n): + return str(n) +``` + +**Tour 2 — 🔴** On ajoute `assert fizzbuzz(3) == "Fizz"` → rouge. **🟢** On ajoute `if n % 3 == 0: return "Fizz"` → vert. + +**Tours suivants** : même chose pour 5, puis 15. Le piège de 15 (multiple de 3 *et* de 5) apparaît naturellement : le test `fizzbuzz(15) == "FizzBuzz"` reste rouge (la fonction renvoie `"Fizz"`) tant que le code ne vérifie pas le cas « multiple de 15 » **avant** les deux autres. + +**🔵 Refactor** : à la fin, on regroupe tout dans un test paramétré et on relit le code : + +```python +def fizzbuzz(n: int) -> str: + if n % 15 == 0: + return "FizzBuzz" + if n % 3 == 0: + return "Fizz" + if n % 5 == 0: + return "Buzz" + return str(n) +``` + +```python +import pytest + +from fizzbuzz import fizzbuzz + + +@pytest.mark.parametrize( + "n, attendu", + [(1, "1"), (2, "2"), (3, "Fizz"), (9, "Fizz"), (5, "Buzz"), (15, "FizzBuzz")], +) +def test_fizzbuzz(n, attendu): + assert fizzbuzz(n) == attendu +``` + +**Pourquoi c'est intéressant ?** Tu réfléchis au comportement voulu *avant* de coder, tu avances par petits pas sûrs, et tu obtiens gratuitement une suite de tests complète. Pas besoin d'être dogmatique : même sans faire du TDD strict, prends l'habitude d'écrire un test **dès que tu trouves un bug** (le test reproduit le bug → tu corriges → il ne reviendra jamais). + +### 9. PEP 8 : le code de la route de Python + +Tu peux conduire en zigzag sur une route vide, mais dès qu'il y a d'autres conducteurs, il faut des règles communes. **PEP 8** est le guide de style officiel de Python : il rend le code de tout le monde **homogène**, donc facile à lire. + +> « Le code est lu beaucoup plus souvent qu'il n'est écrit. » — Guido van Rossum, créateur de Python + +#### 9.1 Les règles principales + +| Règle | ✅ Bien | ❌ À éviter | +|---|---|---| +| Indentation : **4 espaces** (jamais de tabulations) | `····return x` | tabulation, 2 espaces | +| Variables et fonctions en **snake_case** | `prix_total`, `calculer_tva()` | `prixTotal`, `CalculerTVA()` | +| Classes en **CapWords** (PascalCase) | `class CompteBancaire:` | `class compte_bancaire:` | +| Constantes en **MAJUSCULES** | `TAUX_TVA = 0.2` | `tauxTva = 0.2` | +| Espaces autour des opérateurs `=`, `+`, `==`… | `x = a + b` | `x=a+b` | +| Pas d'espace à l'intérieur des parenthèses | `print(x)` | `print( x )` | +| Espace après la virgule | `[1, 2, 3]` | `[1,2,3]` | +| Pas d'espace autour de `=` pour un argument nommé | `f(x, debut=0)` | `f(x, debut = 0)` | +| Lignes courtes (79 selon PEP 8, 88 avec ruff/black) | | lignes de 150 caractères | +| **2 lignes vides** autour des fonctions et classes de haut niveau, 1 entre les méthodes | | tout collé | +| Imports en haut du fichier, **un par ligne**, groupés : standard, puis externes, puis les tiens | `import os`
`import sys` | `import os, sys` | +| Comparer à `None` avec `is` | `if x is None:` | `if x == None:` | +| Tester un booléen directement | `if est_valide:` | `if est_valide == True:` | +| Noms explicites | `nombre_eleves` | `n`, `l`, `tmp2` | + +#### 9.2 Avant / après + +```python +# ❌ Avant : ça fonctionne, mais c'est pénible à lire +import os, sys +import math +def CalculAire(Rayon) : + resultat=math.pi*Rayon**2 + x = 3 + if resultat == None : + return 0 + return resultat +liste = [1,2,3 ,4] +print( CalculAire(2) ) +``` + +```python +# ✅ Après +import math + + +def calculer_aire(rayon): + return math.pi * rayon**2 + + +nombres = [1, 2, 3, 4] +print(calculer_aire(2)) +``` + +Personne n'a envie de vérifier tout ça à la main… et c'est là qu'intervient ruff. + +### 10. ruff : le correcteur automatique + +**ruff** est un outil ultra-rapide qui fait deux choses : + +1. **linter** (`ruff check`) : il repère les erreurs probables et les écarts de style (import inutilisé, variable jamais utilisée, comparaison à `None` avec `==`…) ; +2. **formateur** (`ruff format`) : il **réécrit** automatiquement la mise en forme de ton code (espaces, sauts de ligne, guillemets) selon un style standard. + +C'est comme un correcteur orthographique (check) doublé d'un outil de mise en page (format). + +```bash +pip install ruff +``` + +#### 10.1 `ruff check` : trouver les problèmes + +Sur le fichier « avant » ci-dessus (appelons-le `brouillon.py`), avec les règles que nous configurerons en 10.3 : + +```bash +ruff check brouillon.py +``` + +```text +brouillon.py:1:1: E401 [*] Multiple imports on one line +brouillon.py:1:1: I001 [*] Import block is un-sorted or un-formatted +brouillon.py:1:8: F401 [*] `os` imported but unused +brouillon.py:1:12: F401 [*] `sys` imported but unused +brouillon.py:3:5: N802 Function name `CalculAire` should be lowercase +brouillon.py:3:16: N803 Argument name `Rayon` should be lowercase +brouillon.py:5:5: F841 Local variable `x` is assigned to but never used +brouillon.py:6:20: E711 Comparison to `None` should be `cond is None` +Found 8 errors. +[*] 4 fixable with the `--fix` option (2 hidden fixes can be enabled with the `--unsafe-fixes` option). +``` + +*(Selon ta version de ruff, l'affichage peut être plus détaillé, avec un extrait du code pour chaque erreur ; `--output-format concise` donne cette forme compacte.)* + +Chaque ligne indique : **fichier:ligne:colonne**, un **code de règle** (`F401`…) et une explication. Les règles marquées `[*]` peuvent être corrigées automatiquement : + +```bash +ruff check --fix brouillon.py +``` + +Les autres (noms de fonctions, variable inutile…) demandent ta réflexion : ruff ne renomme pas tes fonctions à ta place. + +Familles de règles courantes : + +| Préfixe | Origine | Exemples | +|---|---|---| +| `F` | Pyflakes | import ou variable inutilisés, nom non défini | +| `E` / `W` | pycodestyle (PEP 8) | lignes trop longues, `== None` | +| `I` | isort | ordre des imports | +| `N` | pep8-naming | conventions de nommage | +| `UP` | pyupgrade | syntaxe Python moderne | +| `B` | flake8-bugbear | pièges classiques (argument par défaut mutable…) | + +#### 10.2 `ruff format` : mettre en forme + +```bash +ruff format brouillon.py # reformate le fichier +ruff format . # reformate tout le projet +ruff format --check . # vérifie seulement, sans modifier (utile avant un commit) +ruff format --diff brouillon.py # montre ce qui serait changé +``` + +Après `ruff check --fix` puis `ruff format`, il ne reste plus qu'à corriger à la main les noms et la variable inutile. + +> 💡 **VS Code** : installe l'extension officielle **Ruff** (éditeur : Astral Software). Elle souligne les problèmes pendant que tu tapes. Dans les réglages, active « Format On Save » et choisis Ruff comme formateur par défaut : ton code est formaté à chaque sauvegarde. ✨ + +#### 10.3 Configurer ruff + +Par défaut, ruff n'active qu'une sélection prudente de règles (les familles `E` et `F` principales). On le configure dans `pyproject.toml` : + +```toml +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B"] +``` + +### 11. Docstrings : documenter son code + +Une **docstring** est une chaîne entre triples guillemets placée **juste sous** la ligne `def` (ou `class`, ou en tête d'un module). Tu les as rencontrées au [chapitre 05](05-fonctions.md). Contrairement à un commentaire `#`, Python la conserve : `help(ma_fonction)` l'affiche, et VS Code la montre au survol. + +**Commentaire ou docstring ?** + +- la docstring explique **quoi** et **comment l'utiliser** (pour l'utilisateur de la fonction) ; +- le commentaire `#` explique **pourquoi** un passage de code est écrit ainsi (pour celui qui lit l'intérieur). + +Pour une fonction simple, une ligne suffit : + +```python +def fahrenheit_vers_celsius(fahrenheit: float) -> float: + """Convertit une température de degrés Fahrenheit en degrés Celsius.""" + return (fahrenheit - 32) * 5 / 9 +``` + +Pour une fonction plus riche, on détaille (ici au style « Google », très lisible) : + +```python +ZERO_ABSOLU_C = -273.15 + + +def celsius_vers_fahrenheit(celsius: float) -> float: + """Convertit une température de degrés Celsius en degrés Fahrenheit. + + Args: + celsius: température en °C, supérieure ou égale au zéro absolu. + + Returns: + La température en °F. + + Raises: + ValueError: si la température est inférieure au zéro absolu. + + Example: + >>> celsius_vers_fahrenheit(100) + 212.0 + """ + if celsius < ZERO_ABSOLU_C: + raise ValueError(f"{celsius} °C est sous le zéro absolu.") + return celsius * 9 / 5 + 32 +``` + +Conventions (PEP 257) : triples guillemets doubles `"""`, première ligne = résumé court terminé par un point, puis une ligne vide avant les détails. + +> 🎁 **Bonus : les doctests.** Les lignes `>>> ...` de l'exemple sont aussi des tests ! `pytest --doctest-modules` exécute ces exemples et vérifie que le résultat affiché est correct. Ta documentation ne peut ainsi jamais mentir. + +### 12. Structurer un projet + +Quand un projet grandit, on range. Voici la structure recommandée aujourd'hui, dite **« src layout »** : + +```text +convertisseur/ ← dossier du projet (= dépôt Git) +├── .venv/ ← environnement virtuel (ignoré par Git) +├── .gitignore +├── README.md ← présentation, installation, utilisation +├── pyproject.toml ← métadonnées, dépendances, config des outils +├── src/ +│ └── convertisseur/ ← le paquet Python (le code « produit ») +│ ├── __init__.py ← fait de ce dossier un paquet (peut être presque vide) +│ ├── temperatures.py +│ └── distances.py +└── tests/ + ├── test_temperatures.py + └── test_distances.py +``` + +Pourquoi séparer `src/` et `tests/` ? Le code du produit et le code qui le vérifie n'ont pas le même rôle. Les tests ne sont pas livrés aux utilisateurs, et ce rangement évite que les tests importent « par accident » des fichiers qui traînent. + +#### 12.1 Le pyproject.toml complet + +Tu as vu `pyproject.toml` au [chapitre 12](12-outils-venv-pip-git.md). Voici une version complète pour ce projet, qui configure aussi pytest et ruff : + +```toml +[project] +name = "convertisseur" +version = "0.1.0" +description = "Conversions d'unités simples" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest", "ruff"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B"] +``` + +- `testpaths = ["tests"]` : pytest cherche les tests dans `tests/` ; +- `pythonpath = ["src"]` : pytest ajoute `src/` aux chemins d'import, ce qui permet d'écrire `from convertisseur.temperatures import ...` dans les tests ; +- la section `[build-system]` permet d'**installer ton propre projet** dans le venv en mode « éditable » (les modifications du code sont prises en compte immédiatement) : + +```bash +pip install -e ".[dev]" # installe le projet + pytest + ruff +``` + +Après cette commande, ton paquet `convertisseur` est importable de n'importe où dans le venv, comme une vraie bibliothèque. + +#### 12.2 Un test dans cette structure + +`tests/test_temperatures.py` : + +```python +import pytest + +from convertisseur.temperatures import ( + celsius_vers_fahrenheit, + fahrenheit_vers_celsius, +) + + +@pytest.mark.parametrize( + "celsius, fahrenheit", + [(0, 32), (100, 212), (-40, -40), (37, 98.6)], +) +def test_celsius_vers_fahrenheit(celsius, fahrenheit): + assert celsius_vers_fahrenheit(celsius) == pytest.approx(fahrenheit) + + +@pytest.mark.parametrize("valeur", [-50, 0, 20.5, 100]) +def test_aller_retour(valeur): + assert fahrenheit_vers_celsius(celsius_vers_fahrenheit(valeur)) == pytest.approx( + valeur + ) + + +def test_sous_zero_absolu(): + with pytest.raises(ValueError, match="zéro absolu"): + celsius_vers_fahrenheit(-300) +``` + +Le test `test_aller_retour` est une jolie idée : convertir dans un sens puis dans l'autre doit redonner la valeur de départ. On appelle ça un test de **propriété**. + +#### 12.3 La routine avant chaque commit + +```bash +ruff check --fix . +ruff format . +pytest +git add . +git commit -m "..." +``` + +Si les trois premières commandes sont vertes, tu peux committer l'esprit tranquille. Les équipes professionnelles automatisent exactement ça sur GitHub (avec GitHub Actions, de l'*intégration continue*) : chaque `push` lance les tests sur un serveur. + +> 💡 **Couverture de code** : le plugin `pytest-cov` (`pip install pytest-cov`, puis `pytest --cov=src`) indique quel pourcentage de ton code est exécuté par les tests. Pratique pour repérer les fonctions oubliées, mais 100 % de couverture ne garantit pas l'absence de bug ! + +--- + +## Pièges fréquents + +### 1. Un test qui ne commence pas par `test_` + +**Symptôme** : pytest affiche `collected 0 items` ou ignore certains tests, alors que le fichier en contient. + +**Correction** : le fichier doit s'appeler `test_xxx.py` et chaque fonction de test `test_xxx`. `verifier_moyenne()` ou `Test_moyenne.py`… ne seront pas trouvés. Utilise `pytest -v` pour voir précisément quels tests sont collectés. + +### 2. `ModuleNotFoundError` dans les tests + +**Symptôme** : `ModuleNotFoundError: No module named 'calculs'` (ou le nom de ton paquet) au lancement de pytest. + +**Correction** : lance pytest **depuis la racine du projet**. Avec une structure `src/`, ajoute `pythonpath = ["src"]` dans la section `[tool.pytest.ini_options]` du `pyproject.toml`, ou installe le projet avec `pip install -e .`. Vérifie aussi que le venv est activé et que pytest y est installé (`python -m pytest` évite d'utiliser un autre pytest). + +### 3. Nommer un fichier comme un module existant + +**Symptôme** : ton fichier `pytest.py`, `random.py` ou `test.py` provoque des erreurs incompréhensibles (`AttributeError: module 'random' has no attribute 'randint'`, `module 'pytest' has no attribute 'raises'`). + +**Correction** : ton fichier masque le vrai module. Renomme-le (`mon_jeu.py`, `test_jeu.py`) et supprime le dossier `__pycache__` qui a pu se créer. + +### 4. Comparer des `float` avec `==` + +**Symptôme** : `assert 0.30000000000000004 == 0.3` échoue alors que le calcul est « juste ». + +**Correction** : utilise `pytest.approx` : `assert resultat == pytest.approx(0.3)`. + +### 5. Code à tester mélangé avec `input()` et `print()` + +**Symptôme** : impossible de tester ta fonction : elle demande une saisie au clavier et affiche le résultat au lieu de le renvoyer. + +**Correction** : sépare la **logique** (fonctions pures qui reçoivent des paramètres et font `return`) de l'**interface** (le `main()` qui fait `input`/`print`). Seule la logique a besoin de tests, et elle devient triviale à tester. + +### 6. `pytest.raises` mal placé + +**Symptôme** : le test échoue avec l'exception elle-même (`ValueError: ...`) au lieu de la « capturer » ; ou le test passe alors qu'il ne devrait pas. + +**Correction** : l'appel qui doit lever l'exception doit être **à l'intérieur** du bloc `with pytest.raises(...)`. Et place **un seul** appel dans ce bloc : si la première ligne lève l'exception, les suivantes ne sont jamais exécutées. + +### 7. Des tests qui dépendent les uns des autres + +**Symptôme** : les tests passent quand on les lance tous, mais l'un d'eux échoue quand on le lance seul (ou dans un autre ordre). + +**Correction** : n'utilise pas de variable globale partagée et modifiée par les tests. Crée les données dans chaque test ou via une **fixture** (qui fournit un objet neuf à chaque test), et utilise `tmp_path` pour les fichiers. + +### 8. Ignorer les avertissements de ruff… ou leur obéir aveuglément + +**Symptôme** : des centaines d'avertissements que tu ne lis plus ; ou au contraire du code tordu pour satisfaire une règle. + +**Correction** : lance ruff **dès le début** du projet, quand il y a peu à corriger. Lis le message et cherche le code de la règle (`ruff rule F841` explique la règle en détail). Si une règle ne convient vraiment pas à ton projet, désactive-la explicitement dans `pyproject.toml` (`ignore = ["E501"]`) plutôt que de l'ignorer en silence. + +--- + +## Exercices + +### Exercice 1 ⭐ — Premiers tests + +Voici `geometrie.py` : + +```python +def carre(n): + return n * n + + +def est_majeur(age): + return age >= 18 + + +def perimetre_rectangle(longueur, largeur): + return 2 * (longueur + largeur) +``` + +Écris `test_geometrie.py` avec **au moins 7 tests** : trois pour `carre` (positif, négatif, zéro), deux pour `est_majeur` (dont le cas limite de 18 ans) et deux pour `perimetre_rectangle`. Lance `pytest -v`. + +### Exercice 2 ⭐ — Chasse aux bugs + +Ce module `bug.py` contient **deux** bugs : + +```python +def valeur_absolue(n): + if n > 0: + return n + return n + + +def maximum(nombres): + plus_grand = 0 + for n in nombres: + if n > plus_grand: + plus_grand = n + return plus_grand +``` + +1. Écris des tests qui **révèlent** ces bugs (ils doivent échouer). Pense aux nombres négatifs ! +2. Lis le rapport de pytest, puis corrige le code jusqu'à ce que tout soit vert. +3. Bonus : que devrait faire `maximum([])` ? Décide d'un comportement, teste-le, implémente-le. + +### Exercice 3 ⭐⭐ — Palindromes paramétrés + +Écris une fonction `est_palindrome(texte)` qui indique si un texte se lit pareil dans les deux sens, **en ignorant** la casse, les espaces et la ponctuation (« Esope reste ici et se repose » est un palindrome). + +Teste-la avec **deux** tests paramétrés : un pour au moins 5 palindromes (dont la chaîne vide et une seule lettre), un pour au moins 3 non-palindromes. + +### Exercice 4 ⭐⭐ — Saisie robuste et `pytest.raises` + +Écris une fonction `convertir_note(texte)` qui transforme une saisie utilisateur en note sur 20 (`float`) : + +- elle accepte `"12"`, `"12.5"` et `"12,5"` (virgule française) ; +- elle lève une `ValueError` avec un message contenant « n'est pas un nombre » si le texte n'est pas convertible ; +- elle lève une `ValueError` avec un message contenant « entre 0 et 20 » si la note est hors limites. + +Écris les tests correspondants avec `parametrize` et `pytest.raises(..., match=...)`. + +### Exercice 5 ⭐⭐ — TDD : évaluer un mot de passe + +En suivant le cycle **rouge → vert → refactor**, écris une fonction `evaluer_mot_de_passe(mdp)` qui renvoie `"faible"`, `"moyen"` ou `"fort"` : + +- moins de 8 caractères → `"faible"` ; +- on compte 4 critères : une minuscule, une majuscule, un chiffre, un caractère spécial parmi `!@#$%&*?-_+=` ; +- 4 critères **et** au moins 12 caractères → `"fort"` ; +- au moins 3 critères → `"moyen"` ; +- sinon → `"faible"`. + +Écris **d'abord** un test, vois-le échouer, puis écris le code minimal… et recommence. Garde une trace des étapes (par exemple un commit Git par tour). + +### Exercice 6 ⭐⭐ — Nettoyage avec ruff + +Voici un fichier `sale.py` qui fonctionne, mais qui est en piteux état : + +```python +import random,os +def TirerDe(Faces = 6) : + return random.randint(1,Faces) +class joueur: + def __init__(self,Nom): + self.Nom=Nom + self.score=0 + def jouer(self): + l=TirerDe() + self.score+=l + if self.score==None: print("bizarre") + return l +j = joueur( "Ana" ) +print(j.jouer()) +``` + +1. Lance `ruff check --select E,F,W,I,N,UP,B sale.py`. Combien d'erreurs ? Classe-les par famille. +2. Corrige ce qui est automatique (`--fix`), formate (`ruff format`), puis corrige le reste à la main. +3. Le résultat final doit passer `ruff check` (mêmes règles) et `ruff format --check` sans erreur, et le programme doit fonctionner comme avant. + +### Exercice 7 ⭐⭐⭐ — Tester une classe avec des fixtures + +Écris une classe `CompteBancaire` avec : + +- `titulaire`, `solde` (0 par défaut) et `historique` (liste de chaînes comme `"+50"` ou `"-30"`) ; +- `deposer(montant)` et `retirer(montant)`, qui lèvent `ValueError` si le montant est nul ou négatif ; +- `retirer` lève une exception personnalisée `SoldeInsuffisantError` si le solde ne suffit pas ; +- `virer_vers(autre_compte, montant)`. + +Écris une suite de tests utilisant **deux fixtures** (`compte_alice` avec 100 € et `compte_bob` vide). Vérifie notamment qu'un virement impossible **ne modifie aucun des deux comptes**. + +### Exercice 8 ⭐⭐⭐ — Tester du code qui manipule des fichiers + +Écris un module `fichiers_texte.py` avec : + +- `compter_lignes_mots(chemin)` qui renvoie un tuple `(nombre_de_lignes, nombre_de_mots)` d'un fichier texte ; +- `ajouter_ligne(chemin, ligne)` qui ajoute une ligne à la fin d'un fichier (et le crée s'il n'existe pas). + +Teste-les avec la fixture intégrée `tmp_path`, et une fixture à toi `fichier_exemple` qui crée un petit fichier de 4 lignes. Teste aussi le fichier vide et le fichier inexistant (`FileNotFoundError`). + +--- + +## Mini-projet + +### 🧰 Une suite de tests pour la bibliothèque « boite_a_outils » + +Tu vas construire une petite bibliothèque de fonctions utilitaires **organisée comme un vrai projet**, et surtout sa **suite de tests complète**. + +#### Cahier des charges + +**Structure** (src layout) : + +```text +boite-a-outils/ +├── .gitignore +├── README.md +├── pyproject.toml +├── src/ +│ └── boite_a_outils/ +│ ├── __init__.py +│ ├── nombres.py +│ └── texte.py +└── tests/ + ├── test_nombres.py + └── test_texte.py +``` + +**Module `nombres.py`** : + +- `est_premier(n)` → `bool` (faux pour n < 2) ; +- `pgcd(a, b)` → plus grand commun diviseur, toujours positif, `pgcd(0, 5) == 5` ; +- `factorielle(n)` → `n!`, `ValueError` si `n < 0` ; +- `moyenne_ponderee(valeurs, coefficients)` → `ValueError` si listes vides, de tailles différentes, ou si la somme des coefficients est nulle. + +**Module `texte.py`** : + +- `retirer_accents(texte)` → `"Élève"` devient `"Eleve"` ; +- `compter_voyelles(texte)` → compte `a e i o u y`, accentuées ou non ; +- `slugifier(titre)` → `"Été 2026 : c'est parti !"` devient `"ete-2026-c-est-parti"` (minuscules, sans accents, mots séparés par des tirets) ; +- `tronquer(texte, longueur_max, suite="…")` → résultat de longueur `<= longueur_max`, suite comprise ; `ValueError` si `longueur_max` est plus petit que la suite. + +**Exigences qualité** : + +1. chaque fonction a une **docstring** (avec au moins deux exemples `>>>` dans le projet) ; +2. au moins **40 tests** (les cas paramétrés comptent), qui utilisent au moins **une fixture**, **`parametrize`** et **`pytest.raises`** ; +3. `pytest` est entièrement vert ; +4. `ruff check` (règles `E, F, W, I, N, UP, B`) et `ruff format --check` ne signalent rien ; +5. le projet est sous Git avec un historique propre. + +#### Étapes suggérées + +1. Crée l'arborescence, le venv, le `pyproject.toml` (inspire-toi de la section 12.1), puis `pip install -e ".[dev]"`. +2. Pour chaque fonction : **écris d'abord ses tests** (cas normal, limites, erreurs), puis la fonction. Lance `pytest` souvent. +3. Ajoute les docstrings, puis vérifie les doctests avec `pytest --doctest-modules src`. +4. Lance `ruff check --fix .` et `ruff format .`, corrige le reste. +5. Rédige le README (comment installer, comment lancer les tests), committe. + +*Indice pour `retirer_accents`* : `unicodedata.normalize("NFD", texte)` décompose « é » en « e » + un accent séparé ; les accents ont la catégorie Unicode `"Mn"` (`unicodedata.category(c)`), il suffit de les filtrer. + +#### Pistes d'amélioration + +- Ajoute `pytest-cov` et vise une couverture de 100 % de `src/`. +- Ajoute un fichier `tests/conftest.py` avec des fixtures partagées. +- Ajoute un module `dates.py` (âge à partir d'une date de naissance, jour de la semaine en français…) en TDD. +- Crée un workflow GitHub Actions qui lance `ruff` et `pytest` à chaque `push` (cherche « GitHub Actions Python » dans la documentation de GitHub). + +--- + +## Corrigés + +
+Corrigé de l'exercice 1 + +```python +from geometrie import carre, est_majeur, perimetre_rectangle + + +def test_carre_positif(): + assert carre(3) == 9 + + +def test_carre_negatif(): + assert carre(-4) == 16 + + +def test_carre_zero(): + assert carre(0) == 0 + + +def test_est_majeur_pile_18_ans(): + assert est_majeur(18) is True + + +def test_est_mineur(): + assert est_majeur(17) is False + + +def test_perimetre_rectangle(): + assert perimetre_rectangle(5, 3) == 16 + + +def test_perimetre_carre(): + assert perimetre_rectangle(2, 2) == 8 +``` + +```bash +pytest -v +``` + +```text +test_geometrie.py::test_carre_positif PASSED +test_geometrie.py::test_carre_negatif PASSED +... +============================== 7 passed in 0.01s =============================== +``` + +**À retenir** : le test de 18 ans est le plus important de la série. C'est une **valeur limite** : un développeur distrait aurait pu écrire `age > 18`, et seul ce test l'aurait détecté. `is True` / `is False` est plus strict que `== True` : on vérifie que la fonction renvoie vraiment un booléen. + +
+ +
+Corrigé de l'exercice 2 + +**1. Tests qui révèlent les bugs** (`test_bug.py`) : + +```python +import pytest + +from bug import maximum, valeur_absolue + + +def test_valeur_absolue_positif(): + assert valeur_absolue(5) == 5 + + +def test_valeur_absolue_negatif(): + assert valeur_absolue(-5) == 5 + + +def test_valeur_absolue_zero(): + assert valeur_absolue(0) == 0 + + +def test_maximum_positifs(): + assert maximum([3, 8, 1]) == 8 + + +def test_maximum_negatifs(): + assert maximum([-7, -2, -9]) == -2 + + +def test_maximum_liste_vide(): + with pytest.raises(ValueError): + maximum([]) +``` + +Avec le code bogué, pytest affiche : + +```text +E assert -5 == 5 +E + where -5 = valeur_absolue(-5) +... +E assert 0 == -2 +E + where 0 = maximum([-7, -2, -9]) +``` + +Les diagnostics parlent d'eux-mêmes : `valeur_absolue` renvoie le nombre négatif tel quel, et `maximum` renvoie 0, une valeur qui n'est même pas dans la liste ! + +**2–3. Code corrigé** : + +```python +def valeur_absolue(n): + if n >= 0: + return n + return -n + + +def maximum(nombres): + if not nombres: + raise ValueError("maximum() d'une liste vide") + plus_grand = nombres[0] + for n in nombres[1:]: + if n > plus_grand: + plus_grand = n + return plus_grand +``` + +**Explications** : + +- `valeur_absolue` : il manquait le `-` dans le second `return`. +- `maximum` : partir de `0` est une erreur classique. Si tous les nombres sont négatifs, aucun n'est plus grand que 0. Il faut partir du **premier élément** de la liste. Pour la liste vide, il n'y a pas de maximum ; lever une `ValueError` est le choix cohérent avec la fonction `max()` de Python (qui fait pareil). +- Les tests sur les nombres positifs passaient déjà : **sans cas négatifs, les bugs seraient passés inaperçus.** + +
+ +
+Corrigé de l'exercice 3 + +`palindrome.py` : + +```python +def est_palindrome(texte: str) -> bool: + """Indique si texte se lit pareil dans les deux sens. + + On ignore la casse, les espaces et la ponctuation. + """ + lettres = [c.lower() for c in texte if c.isalnum()] + return lettres == lettres[::-1] +``` + +`test_palindrome.py` : + +```python +import pytest + +from palindrome import est_palindrome + + +@pytest.mark.parametrize( + "texte", + [ + "kayak", + "Radar", + "Esope reste ici et se repose", + "A man, a plan, a canal: Panama!", + "", + "x", + ], +) +def test_palindromes(texte): + assert est_palindrome(texte) + + +@pytest.mark.parametrize("texte", ["python", "Bonjour", "ab"]) +def test_non_palindromes(texte): + assert not est_palindrome(texte) +``` + +Résultat : `9 passed`. + +**Explications** : + +- La compréhension de liste ([chapitre 06](06-chaines-et-comprehensions.md)) garde uniquement les caractères alphanumériques (`isalnum()`), en minuscules. +- `lettres[::-1]` est la liste à l'envers (slicing avec un pas de -1). +- Avec un seul paramètre, `parametrize` accepte une simple liste de valeurs (pas besoin de tuples). +- La chaîne vide et la lettre unique sont des **cas limites** : par convention, ce sont des palindromes. + +
+ +
+Corrigé de l'exercice 4 + +`notes.py` : + +```python +def convertir_note(texte: str) -> float: + """Convertit une saisie utilisateur en note sur 20. + + Accepte la virgule comme séparateur décimal ("12,5"). + + Raises: + ValueError: si le texte n'est pas un nombre ou si la note + n'est pas comprise entre 0 et 20. + """ + try: + note = float(texte.replace(",", ".")) + except ValueError: + raise ValueError(f"« {texte} » n'est pas un nombre.") from None + if not 0 <= note <= 20: + raise ValueError(f"La note {note} doit être entre 0 et 20.") + return note +``` + +`test_notes.py` : + +```python +import pytest + +from notes import convertir_note + + +@pytest.mark.parametrize( + "saisie, attendu", + [("12", 12.0), ("12.5", 12.5), ("12,5", 12.5), ("0", 0.0), ("20", 20.0)], +) +def test_conversion_valide(saisie, attendu): + assert convertir_note(saisie) == attendu + + +@pytest.mark.parametrize("saisie", ["douze", "", "12/20"]) +def test_pas_un_nombre(saisie): + with pytest.raises(ValueError, match="n'est pas un nombre"): + convertir_note(saisie) + + +@pytest.mark.parametrize("saisie", ["-1", "20.5", "100"]) +def test_hors_limites(saisie): + with pytest.raises(ValueError, match="entre 0 et 20"): + convertir_note(saisie) +``` + +Résultat : `11 passed`. + +**Explications** : + +- On attrape la `ValueError` de `float()` pour la relancer avec **notre** message, plus clair pour l'utilisateur. `from None` masque l'exception d'origine dans le traceback ([chapitre 08](08-erreurs-et-debogage.md)). +- `0 <= note <= 20` : Python permet d'enchaîner les comparaisons. +- `match=` distingue les **deux** sortes d'erreurs : sans lui, un test « hors limites » passerait même si la fonction se trompait de raison. +- Les cas `"0"` et `"20"` vérifient que les bornes sont **incluses**. + +
+ +
+Corrigé de l'exercice 5 + +**Déroulé TDD** (une possibilité) : + +| Tour | Test ajouté (🔴) | Code ajouté (🟢) | +|---|---|---| +| 1 | `"Ab1!"` → `"faible"` | `return "faible"` (oui, c'est tout !) | +| 2 | `"Bonjour2026!Py"` → `"fort"` | compter les 4 critères ; `if nombre == 4 and len >= 12: return "fort"` | +| 3 | `"Bonjour123"` → `"moyen"` | `if nombre >= 3: return "moyen"` | +| 4 | `"abcdefghijkl"` → `"faible"` | déjà vert : le test confirme le comportement | +| 5 | `"Bonjour1!"` → `"moyen"` (4 critères, mais 9 caractères) | déjà vert | +| 🔵 | regroupement dans un test paramétré | extraction de la constante `CARACTERES_SPECIAUX` | + +**Code final** (`mot_de_passe.py`) : + +```python +CARACTERES_SPECIAUX = "!@#$%&*?-_+=" + + +def evaluer_mot_de_passe(mot_de_passe: str) -> str: + """Renvoie "faible", "moyen" ou "fort" selon la robustesse du mot de passe.""" + if len(mot_de_passe) < 8: + return "faible" + criteres = [ + any(c.islower() for c in mot_de_passe), + any(c.isupper() for c in mot_de_passe), + any(c.isdigit() for c in mot_de_passe), + any(c in CARACTERES_SPECIAUX for c in mot_de_passe), + ] + nombre = sum(criteres) + if nombre == 4 and len(mot_de_passe) >= 12: + return "fort" + if nombre >= 3: + return "moyen" + return "faible" +``` + +**Tests** (`test_mot_de_passe.py`) : + +```python +import pytest + +from mot_de_passe import evaluer_mot_de_passe + + +def test_trop_court_est_faible(): + assert evaluer_mot_de_passe("Ab1!") == "faible" + + +def test_long_mais_que_des_minuscules_est_faible(): + assert evaluer_mot_de_passe("abcdefghijkl") == "faible" + + +def test_trois_criteres_est_moyen(): + assert evaluer_mot_de_passe("Bonjour123") == "moyen" + + +def test_quatre_criteres_mais_court_est_moyen(): + assert evaluer_mot_de_passe("Bonjour1!") == "moyen" + + +def test_quatre_criteres_et_long_est_fort(): + assert evaluer_mot_de_passe("Bonjour2026!Py") == "fort" + + +@pytest.mark.parametrize( + "mot_de_passe, attendu", + [ + ("", "faible"), + ("12345678", "faible"), + ("motdepasse12", "faible"), + ("MotDePasse12", "moyen"), + ("M0t-De-Passe-Solide", "fort"), + ], +) +def test_cas_varies(mot_de_passe, attendu): + assert evaluer_mot_de_passe(mot_de_passe) == attendu +``` + +**Explications** : + +- `any(...)` renvoie `True` si au moins un caractère satisfait la condition. +- `sum(criteres)` compte les `True` (car `True` vaut 1 et `False` vaut 0). +- Le tour 1 peut sembler absurde (`return "faible"`), mais c'est le principe : **le minimum pour passer au vert**. Ce sont les tests suivants qui « forcent » le code à devenir général. + +
+ +
+Corrigé de l'exercice 6 + +**1. Diagnostic** : + +```bash +ruff check --select E,F,W,I,N,UP,B --output-format concise sale.py +``` + +```text +sale.py:1:1: E401 [*] Multiple imports on one line +sale.py:1:1: I001 [*] Import block is un-sorted or un-formatted +sale.py:1:15: F401 [*] `os` imported but unused +sale.py:2:5: N802 Function name `TirerDe` should be lowercase +sale.py:2:13: N803 Argument name `Faces` should be lowercase +sale.py:4:7: N801 Class name `joueur` should use CapWords convention +sale.py:5:21: N803 Argument name `Nom` should be lowercase +sale.py:9:7: E741 Ambiguous variable name: `l` +sale.py:11:22: E711 Comparison to `None` should be `cond is None` +sale.py:11:26: E701 Multiple statements on one line (colon) +Found 10 errors. +``` + +10 erreurs : 4 de style PEP 8 (`E`), 1 d'ordre des imports (`I`), 1 import inutilisé (`F`), 4 de nommage (`N`). Note que l'indentation à 2 espaces n'est pas signalée par ces règles, mais `ruff format` la corrigera. + +**2. Correction** : `ruff check --fix` supprime `os` et range l'import ; `ruff format` corrige l'indentation et les espaces. Le reste à la main : + +- `TirerDe` → `tirer_de`, `Faces` → `faces`, `Nom` → `nom` (snake_case) ; +- `joueur` → `Joueur` (CapWords pour une classe) ; +- `l` → `resultat` (`l` se confond avec `1` et `I`) ; +- la ligne `if self.score == None: print("bizarre")` est inutile (le score ne peut pas être `None`) : on la supprime ; +- `j` → `joueur`. + +```python +import random + + +def tirer_de(faces=6): + return random.randint(1, faces) + + +class Joueur: + def __init__(self, nom): + self.nom = nom + self.score = 0 + + def jouer(self): + resultat = tirer_de() + self.score += resultat + return resultat + + +joueur = Joueur("Ana") +print(joueur.jouer()) +``` + +**3. Vérification** : + +```bash +ruff check --select E,F,W,I,N,UP,B propre.py # All checks passed! +ruff format --check propre.py # 1 file already formatted +python propre.py # affiche un nombre entre 1 et 6 +``` + +
+ +
+Corrigé de l'exercice 7 + +`compte.py` : + +```python +class SoldeInsuffisantError(Exception): + """Levée quand un retrait dépasse le solde disponible.""" + + +class CompteBancaire: + def __init__(self, titulaire: str, solde: float = 0.0): + self.titulaire = titulaire + self.solde = solde + self.historique: list[str] = [] + + def deposer(self, montant: float) -> None: + if montant <= 0: + raise ValueError("Le montant doit être positif.") + self.solde += montant + self.historique.append(f"+{montant}") + + def retirer(self, montant: float) -> None: + if montant <= 0: + raise ValueError("Le montant doit être positif.") + if montant > self.solde: + raise SoldeInsuffisantError(f"Solde insuffisant : {self.solde} €") + self.solde -= montant + self.historique.append(f"-{montant}") + + def virer_vers(self, autre: "CompteBancaire", montant: float) -> None: + self.retirer(montant) + autre.deposer(montant) +``` + +`test_compte.py` : + +```python +import pytest + +from compte import CompteBancaire, SoldeInsuffisantError + + +@pytest.fixture +def compte_alice(): + return CompteBancaire("Alice", 100) + + +@pytest.fixture +def compte_bob(): + return CompteBancaire("Bob") + + +def test_creation(compte_alice, compte_bob): + assert compte_alice.titulaire == "Alice" + assert compte_alice.solde == 100 + assert compte_bob.solde == 0 + assert compte_bob.historique == [] + + +def test_depot(compte_alice): + compte_alice.deposer(50) + assert compte_alice.solde == 150 + assert compte_alice.historique == ["+50"] + + +def test_retrait(compte_alice): + compte_alice.retirer(30) + assert compte_alice.solde == 70 + + +def test_retrait_trop_gros(compte_alice): + with pytest.raises(SoldeInsuffisantError): + compte_alice.retirer(500) + assert compte_alice.solde == 100 # le solde n'a pas bougé + + +@pytest.mark.parametrize("montant", [0, -10]) +def test_montant_invalide(compte_alice, montant): + with pytest.raises(ValueError): + compte_alice.deposer(montant) + with pytest.raises(ValueError): + compte_alice.retirer(montant) + + +def test_virement(compte_alice, compte_bob): + compte_alice.virer_vers(compte_bob, 40) + assert compte_alice.solde == 60 + assert compte_bob.solde == 40 + + +def test_virement_impossible_ne_change_rien(compte_alice, compte_bob): + with pytest.raises(SoldeInsuffisantError): + compte_alice.virer_vers(compte_bob, 1000) + assert compte_alice.solde == 100 + assert compte_bob.solde == 0 +``` + +Résultat : `8 passed`. + +**Explications** : + +- Un test peut recevoir **plusieurs fixtures**, et combiner fixture + `parametrize` (`test_montant_invalide`). +- Le nom `SoldeInsuffisantError` suit la convention (règle ruff `N818`) : les exceptions se terminent par `Error`. +- `virer_vers` appelle `retirer` **avant** `deposer` : si le retrait échoue, l'exception interrompt la méthode et `deposer` n'est jamais appelée. Le dernier test le prouve. Si on avait écrit l'inverse, Bob aurait reçu de l'argent qu'Alice n'avait pas ! +- Après `pytest.raises`, on peut continuer le test (en dehors du `with`) pour vérifier l'état des objets. + +
+ +
+Corrigé de l'exercice 8 + +`fichiers_texte.py` : + +```python +from pathlib import Path + + +def compter_lignes_mots(chemin: Path) -> tuple[int, int]: + """Renvoie (nombre de lignes, nombre de mots) d'un fichier texte.""" + texte = Path(chemin).read_text(encoding="utf-8") + return len(texte.splitlines()), len(texte.split()) + + +def ajouter_ligne(chemin: Path, ligne: str) -> None: + """Ajoute une ligne à la fin du fichier (le crée s'il n'existe pas).""" + with open(chemin, "a", encoding="utf-8") as fichier: + fichier.write(ligne + "\n") +``` + +`test_fichiers_texte.py` : + +```python +import pytest + +from fichiers_texte import ajouter_ligne, compter_lignes_mots + + +@pytest.fixture +def fichier_exemple(tmp_path): + chemin = tmp_path / "poeme.txt" + chemin.write_text("Le ciel est bleu\nLa mer est calme\n\nFin\n", encoding="utf-8") + return chemin + + +def test_compter(fichier_exemple): + assert compter_lignes_mots(fichier_exemple) == (4, 9) + + +def test_fichier_vide(tmp_path): + chemin = tmp_path / "vide.txt" + chemin.write_text("", encoding="utf-8") + assert compter_lignes_mots(chemin) == (0, 0) + + +def test_fichier_inexistant(tmp_path): + with pytest.raises(FileNotFoundError): + compter_lignes_mots(tmp_path / "absent.txt") + + +def test_ajouter_ligne_cree_le_fichier(tmp_path): + chemin = tmp_path / "journal.txt" + ajouter_ligne(chemin, "Bonjour") + assert chemin.read_text(encoding="utf-8") == "Bonjour\n" + + +def test_ajouter_ligne_a_la_suite(fichier_exemple): + ajouter_ligne(fichier_exemple, "Nouvelle ligne") + assert compter_lignes_mots(fichier_exemple) == (5, 11) +``` + +Résultat : `5 passed`. + +**Explications** : + +- Une fixture peut **utiliser une autre fixture** : `fichier_exemple` reçoit `tmp_path`. Chaque test a donc son propre fichier, dans son propre dossier temporaire. +- Le fichier d'exemple a 4 lignes (dont une vide) et 9 mots : `splitlines()` compte les lignes, `split()` sans argument découpe sur tous les blancs et ignore les lignes vides. +- Le mode `"a"` (*append*) ajoute à la fin et crée le fichier s'il n'existe pas ([chapitre 07](07-fichiers-csv-json.md)). +- Aucun fichier n'est créé dans ton projet : tout se passe dans un dossier temporaire géré par pytest. + +
+ +
+Corrigé du mini-projet + +**`pyproject.toml`** : + +```toml +[project] +name = "boite-a-outils" +version = "0.1.0" +description = "Petite bibliothèque de fonctions utilitaires" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest", "ruff"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B"] +``` + +(Sans section `[build-system]`, on n'installe pas le projet avec `pip install -e .` : on installe juste les outils avec `pip install pytest ruff`, et `pythonpath = ["src"]` suffit pour que les tests trouvent le paquet.) + +**`src/boite_a_outils/__init__.py`** : + +```python +"""Boîte à outils : fonctions utilitaires sur les nombres et les textes.""" +``` + +**`src/boite_a_outils/nombres.py`** : + +```python +"""Fonctions utilitaires sur les nombres.""" + + +def est_premier(n: int) -> bool: + """Indique si n est un nombre premier. + + >>> est_premier(7) + True + >>> est_premier(8) + False + """ + if n < 2: + return False + diviseur = 2 + while diviseur * diviseur <= n: + if n % diviseur == 0: + return False + diviseur += 1 + return True + + +def pgcd(a: int, b: int) -> int: + """Renvoie le plus grand commun diviseur de a et b (algorithme d'Euclide).""" + a, b = abs(a), abs(b) + while b != 0: + a, b = b, a % b + return a + + +def factorielle(n: int) -> int: + """Renvoie n! = 1 × 2 × … × n. + + Raises: + ValueError: si n est négatif. + """ + if n < 0: + raise ValueError("La factorielle n'est pas définie pour un nombre négatif.") + resultat = 1 + for i in range(2, n + 1): + resultat *= i + return resultat + + +def moyenne_ponderee(valeurs: list[float], coefficients: list[float]) -> float: + """Renvoie la moyenne de valeurs pondérée par coefficients. + + Raises: + ValueError: si les listes sont vides, de tailles différentes, + ou si la somme des coefficients est nulle. + """ + if not valeurs or len(valeurs) != len(coefficients): + raise ValueError("Listes vides ou de tailles différentes.") + total_coefficients = sum(coefficients) + if total_coefficients == 0: + raise ValueError("La somme des coefficients ne peut pas être nulle.") + somme = sum(v * c for v, c in zip(valeurs, coefficients, strict=True)) + return somme / total_coefficients +``` + +**`src/boite_a_outils/texte.py`** : + +```python +"""Fonctions utilitaires sur les chaînes de caractères.""" + +import unicodedata + + +def compter_voyelles(texte: str) -> int: + """Compte les voyelles (accentuées ou non, y compris y) dans texte.""" + sans_accents = retirer_accents(texte.lower()) + return sum(1 for lettre in sans_accents if lettre in "aeiouy") + + +def retirer_accents(texte: str) -> str: + """Remplace les lettres accentuées par leur équivalent sans accent. + + >>> retirer_accents("Élève à l'école") + "Eleve a l'ecole" + """ + decompose = unicodedata.normalize("NFD", texte) + return "".join(c for c in decompose if unicodedata.category(c) != "Mn") + + +def slugifier(titre: str) -> str: + """Transforme un titre en « slug » utilisable dans une URL. + + >>> slugifier(" Bonjour, le Monde ! ") + 'bonjour-le-monde' + """ + texte = retirer_accents(titre.lower()) + mots = [] + mot_courant = "" + for caractere in texte: + if caractere.isalnum(): + mot_courant += caractere + elif mot_courant: + mots.append(mot_courant) + mot_courant = "" + if mot_courant: + mots.append(mot_courant) + return "-".join(mots) + + +def tronquer(texte: str, longueur_max: int, suite: str = "…") -> str: + """Coupe texte pour qu'il fasse au plus longueur_max caractères, suite comprise. + + Raises: + ValueError: si longueur_max est plus petit que la longueur de suite. + """ + if longueur_max < len(suite): + raise ValueError("longueur_max est trop petit.") + if len(texte) <= longueur_max: + return texte + return texte[: longueur_max - len(suite)] + suite +``` + +**`tests/test_nombres.py`** : + +```python +import pytest + +from boite_a_outils.nombres import est_premier, factorielle, moyenne_ponderee, pgcd + +# --- est_premier ----------------------------------------------------------- + + +@pytest.mark.parametrize("n", [2, 3, 5, 7, 11, 13, 97, 7919]) +def test_est_premier_vrai(n): + assert est_premier(n) is True + + +@pytest.mark.parametrize("n", [-7, 0, 1, 4, 9, 15, 100, 7917]) +def test_est_premier_faux(n): + assert est_premier(n) is False + + +def test_premiers_jusqua_30(): + attendus = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] + assert [n for n in range(31) if est_premier(n)] == attendus + + +# --- pgcd ------------------------------------------------------------------ + + +@pytest.mark.parametrize( + "a, b, attendu", + [ + (12, 18, 6), + (18, 12, 6), # l'ordre ne compte pas + (7, 13, 1), # nombres premiers entre eux + (0, 5, 5), + (5, 0, 5), + (-12, 18, 6), # le PGCD est toujours positif + (42, 42, 42), + ], +) +def test_pgcd(a, b, attendu): + assert pgcd(a, b) == attendu + + +# --- factorielle ----------------------------------------------------------- + + +@pytest.mark.parametrize("n, attendu", [(0, 1), (1, 1), (5, 120), (10, 3628800)]) +def test_factorielle(n, attendu): + assert factorielle(n) == attendu + + +def test_factorielle_negative(): + with pytest.raises(ValueError, match="négatif"): + factorielle(-1) + + +# --- moyenne_ponderee ------------------------------------------------------ + + +def test_moyenne_ponderee_coefficients_egaux(): + assert moyenne_ponderee([10, 20], [1, 1]) == 15 + + +def test_moyenne_ponderee_bulletin(): + # maths 16 (coef 4), français 12 (coef 3), sport 18 (coef 1) + resultat = moyenne_ponderee([16, 12, 18], [4, 3, 1]) + assert resultat == pytest.approx(14.75) + + +@pytest.mark.parametrize( + "valeurs, coefficients", + [ + ([], []), # vides + ([10, 12], [1]), # tailles différentes + ([10, 12], [0, 0]), # somme des coefficients nulle + ], +) +def test_moyenne_ponderee_erreurs(valeurs, coefficients): + with pytest.raises(ValueError): + moyenne_ponderee(valeurs, coefficients) +``` + +**`tests/test_texte.py`** : + +```python +import pytest + +from boite_a_outils.texte import ( + compter_voyelles, + retirer_accents, + slugifier, + tronquer, +) + + +@pytest.fixture +def phrase_accentuee(): + return "L'été à Noël, où ça ?" + + +# --- retirer_accents ------------------------------------------------------- + + +def test_retirer_accents(phrase_accentuee): + assert retirer_accents(phrase_accentuee) == "L'ete a Noel, ou ca ?" + + +def test_retirer_accents_majuscules(): + assert retirer_accents("ÉCOLE") == "ECOLE" + + +def test_retirer_accents_sans_accent_inchange(): + assert retirer_accents("Python") == "Python" + + +# --- compter_voyelles ------------------------------------------------------ + + +@pytest.mark.parametrize( + "texte, attendu", + [("bonjour", 3), ("Python", 2), ("", 0), ("xkcd", 0), ("Élève", 3)], +) +def test_compter_voyelles(texte, attendu): + assert compter_voyelles(texte) == attendu + + +# --- slugifier ------------------------------------------------------------- + + +@pytest.mark.parametrize( + "titre, attendu", + [ + ("Bonjour le monde", "bonjour-le-monde"), + (" Espaces multiples ", "espaces-multiples"), + ("Été 2026 : c'est parti !", "ete-2026-c-est-parti"), + ("!!!", ""), + ], +) +def test_slugifier(titre, attendu): + assert slugifier(titre) == attendu + + +def test_slugifier_resultat_sans_espace_ni_majuscule(phrase_accentuee): + slug = slugifier(phrase_accentuee) + assert " " not in slug + assert slug == slug.lower() + + +# --- tronquer -------------------------------------------------------------- + + +def test_tronquer_texte_court_inchange(): + assert tronquer("Salut", 10) == "Salut" + + +def test_tronquer_texte_pile_a_la_limite(): + assert tronquer("1234567890", 10) == "1234567890" + + +def test_tronquer_texte_long(): + resultat = tronquer("Bonjour tout le monde", 10) + assert resultat == "Bonjour t…" + assert len(resultat) == 10 + + +def test_tronquer_suite_personnalisee(): + assert tronquer("Bonjour tout le monde", 10, suite="...") == "Bonjour..." + + +def test_tronquer_longueur_trop_petite(): + with pytest.raises(ValueError): + tronquer("Bonjour", 2, suite="...") +``` + +**Vérifications** : + +```bash +pytest +``` + +```text +tests/test_nombres.py .................................. [ 65%] +tests/test_texte.py .................. [100%] + +============================== 52 passed in 0.07s ============================== +``` + +```bash +pytest --doctest-modules src -q # 3 passed (les exemples >>> des docstrings) +ruff check . # All checks passed! +ruff format --check . # 5 files already formatted +``` + +**Points clés de la correction** : + +- **52 tests** pour 8 fonctions : `parametrize` permet d'être exhaustif sans répétition. +- La règle ruff `B905` impose `zip(..., strict=True)` : si les deux listes n'ont pas la même longueur, `zip` lève une erreur au lieu de couper silencieusement. Ici, on a déjà vérifié les longueurs juste avant, mais c'est une sécurité gratuite. +- `est_premier` ne teste les diviseurs que jusqu'à la racine carrée de `n` (`diviseur * diviseur <= n`) : c'est bien plus rapide, et les tests sur `7919` (premier) et `7917` (= 3 × 7 × 13 × 29) vérifient que l'optimisation n'a rien cassé. +- `retirer_accents` utilise `unicodedata` (bibliothèque standard) : « é » est décomposé en « e » + « ◌́ » (catégorie `Mn`, *Mark, nonspacing*), qu'on filtre. +- `slugifier` réutilise `retirer_accents` : on teste chaque brique séparément, puis leur combinaison. +- Les commentaires `# --- ... ---` découpent les fichiers de tests en sections lisibles. + +
+ +--- + +## Récapitulatif + +**Points clés à retenir :** + +- Un **test automatique** est une fonction qui appelle ton code et vérifie le résultat avec `assert`. Il se relance à volonté et te protège des régressions. +- **pytest** trouve les fichiers `test_*.py` et les fonctions `test_*`. On lance `pytest` (ou `python -m pytest`), avec `-v`, `-k`, `-x` au besoin. +- Les rapports d'échec montrent la ligne et les valeurs comparées : **lis-les attentivement**. +- `pytest.approx` pour les `float`, `pytest.raises` (avec `match=`) pour les exceptions, `@pytest.mark.parametrize` pour tester de nombreux cas, `@pytest.fixture` pour préparer des données neuves à chaque test, `tmp_path` pour les fichiers. +- Teste les **cas limites** : vide, zéro, négatif, seuil exact, entrée invalide. +- **TDD** : 🔴 test qui échoue → 🟢 code minimal → 🔵 refactor. +- **PEP 8** : 4 espaces, `snake_case` pour variables et fonctions, `CapWords` pour les classes, `MAJUSCULES` pour les constantes, espaces autour des opérateurs, imports en haut. +- **ruff** : `ruff check --fix` (lint) et `ruff format` (mise en forme), configurés dans `pyproject.toml`. +- **Docstrings** : `"""Résumé."""` sous chaque fonction, détails `Args/Returns/Raises` si utile, exemples `>>>` testables. +- **Structure** : `src/paquet/`, `tests/`, `README.md`, `pyproject.toml`, `.gitignore`. + +**Checklist :** + +- [ ] J'ai installé pytest et ruff dans mon venv. +- [ ] Je nomme mes fichiers et fonctions de test avec le préfixe `test_`. +- [ ] Je sais lire un rapport d'échec de pytest. +- [ ] Je sais tester une exception avec `pytest.raises`. +- [ ] J'utilise `parametrize` plutôt que de copier-coller des tests. +- [ ] Je sais écrire une fixture et utiliser `tmp_path`. +- [ ] J'ai essayé le cycle TDD rouge → vert → refactor au moins une fois. +- [ ] Mon code respecte PEP 8 : `ruff check` et `ruff format --check` ne signalent rien. +- [ ] Mes fonctions ont des docstrings. +- [ ] Je sais organiser un projet avec `src/`, `tests/` et `pyproject.toml`. +- [ ] Je lance `ruff` et `pytest` avant chaque commit. + +--- + +[← Chapitre précédent](12-outils-venv-pip-git.md) | [Sommaire](README.md) | [Chapitre suivant →](14-specialisations.md) diff --git a/docs/presentations/Python/14-specialisations.md b/docs/presentations/Python/14-specialisations.md new file mode 100644 index 0000000..c2d3f5b --- /dev/null +++ b/docs/presentations/Python/14-specialisations.md @@ -0,0 +1,2274 @@ +# Chapitre 14 — Les spécialisations : automatisation, web, data, IA et jeux + +> **⏱️ Durée estimée** : 8 à 12 heures pour tout parcourir (tu peux aussi ne lire en détail que les domaines qui t'attirent) +> +> **📋 Prérequis** : avoir terminé le [Chapitre 13 — Tests avec pytest et bonnes pratiques](13-tests-pytest-bonnes-pratiques.md), savoir créer un venv et installer des paquets ([Chapitre 12](12-outils-venv-pip-git.md)), être à l'aise avec les fichiers ([Chapitre 07](07-fichiers-csv-json.md)) et les classes ([Chapitre 10](10-poo-classes-objets.md)). +> +> **🎯 Objectifs** — À la fin de ce chapitre, tu sauras… +> +> - **automatiser** des tâches répétitives sur tes fichiers avec `pathlib`, `shutil` et `argparse` ; +> - créer une petite **application web** avec Flask et une **API** avec FastAPI ; +> - charger, filtrer, résumer des **données** avec pandas et les représenter avec matplotlib ; +> - entraîner et évaluer un premier modèle de **machine learning** avec scikit-learn ; +> - programmer la **boucle de jeu** d'un mini-jeu avec pygame ; +> - choisir une spécialisation et suivre un **parcours d'apprentissage** pour progresser. + +--- + +## Cours + +Félicitations ! 🎉 Tu maîtrises maintenant les bases de Python : variables, conditions, boucles, structures de données, fonctions, fichiers, exceptions, modules, classes, tests et outils. C'est comme si tu avais appris à lire, écrire et compter. + +La question devient : **qu'est-ce que tu veux construire ?** Python est un couteau suisse. Le même langage sert à ranger tes photos, faire tourner un site web, analyser des millions de lignes de données, entraîner une intelligence artificielle ou créer un jeu. Chaque domaine a ses propres **bibliothèques**, ses habitudes et ses outils. + +Ce chapitre est une **visite guidée** de cinq grandes spécialisations. Pour chacune : + +1. à quoi ça sert, concrètement ; +2. les bibliothèques clés ; +3. un **exemple complet et fonctionnel** à recopier et lancer ; +4. un mini-parcours pour aller plus loin. + +> 💡 **Conseil d'organisation** : crée un dossier `specialisations/` avec **un venv par domaine** (`automatisation/`, `web/`, `data/`, `ia/`, `jeux/`). Les bibliothèques de data et d'IA sont volumineuses : inutile de les installer là où tu fais du web. + +```bash +mkdir -p specialisations/web && cd specialisations/web +python3 -m venv .venv # Windows : py -m venv .venv +source .venv/bin/activate # Windows : .venv\Scripts\Activate.ps1 +``` + +### 1. Automatisation : faire travailler l'ordinateur à ta place + +#### 1.1 À quoi ça sert ? + +Toute tâche **répétitive** sur un ordinateur peut probablement être automatisée : renommer 300 photos, trier un dossier « Téléchargements » en désordre, fusionner des fichiers Excel chaque lundi, envoyer un e-mail de rappel, récupérer une information sur un site web tous les matins, sauvegarder un dossier… + +C'est souvent **la spécialisation la plus rentable pour un débutant** : quelques dizaines de lignes de Python te font gagner des heures, dès maintenant, sans rien installer d'autre que Python. + +> 🧠 Règle empirique : si tu fais la même manipulation plus de trois fois, ou si elle prend plus de dix minutes, écris un script. + +#### 1.2 Bibliothèques clés + +| Bibliothèque | Rôle | Installation | +|---|---|---| +| `pathlib`, `shutil`, `os` | parcourir, copier, déplacer, renommer des fichiers | incluses dans Python | +| `argparse` | créer des commandes avec options (`--aide`, `--executer`…) | incluse | +| `csv`, `json`, `zipfile` | lire/écrire des formats courants, compresser | incluses | +| `datetime`, `time`, `sched` | dates, attentes, planification simple | incluses | +| `subprocess` | lancer d'autres programmes | incluse | +| `requests` | télécharger des pages web, appeler des API | `pip install requests` | +| `beautifulsoup4` | extraire des informations d'une page HTML (*scraping*) | `pip install beautifulsoup4` | +| `openpyxl` | lire/écrire des fichiers Excel `.xlsx` | `pip install openpyxl` | +| `pypdf` | lire, fusionner, découper des PDF | `pip install pypdf` | +| `Pillow` | redimensionner, convertir des images | `pip install pillow` | +| `schedule` | lancer une fonction à heure fixe | `pip install schedule` | +| `playwright` / `selenium` | piloter un vrai navigateur | `pip install playwright` | + +#### 1.3 Exemple complet : trier un dossier par type de fichier + +Ce script range chaque fichier d'un dossier dans un sous-dossier selon son extension (`Images/`, `Documents/`, `Audio/`…). Par sécurité, il fonctionne d'abord en **mode simulation** : il affiche ce qu'il ferait sans rien toucher. Il faut ajouter `--executer` pour qu'il déplace vraiment les fichiers. + +Enregistre-le sous `trier_fichiers.py` : + +```python +"""Range les fichiers d'un dossier dans des sous-dossiers selon leur type. + +Usage : + python trier_fichiers.py chemin/du/dossier # simulation + python trier_fichiers.py chemin/du/dossier --executer # pour de vrai +""" + +import argparse +import shutil +from pathlib import Path + +CATEGORIES = { + "Images": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}, + "Documents": {".pdf", ".docx", ".odt", ".txt", ".md", ".xlsx", ".csv"}, + "Audio": {".mp3", ".wav", ".flac", ".ogg"}, + "Videos": {".mp4", ".mkv", ".avi", ".mov"}, + "Archives": {".zip", ".tar", ".gz", ".7z", ".rar"}, + "Code": {".py", ".js", ".html", ".css", ".json"}, +} + + +def trouver_categorie(fichier: Path) -> str: + """Renvoie le nom de la catégorie d'un fichier d'après son extension.""" + extension = fichier.suffix.lower() + for categorie, extensions in CATEGORIES.items(): + if extension in extensions: + return categorie + return "Autres" + + +def destination_libre(destination: Path) -> Path: + """Ajoute un numéro au nom si un fichier du même nom existe déjà.""" + if not destination.exists(): + return destination + compteur = 1 + while True: + candidat = destination.with_stem(f"{destination.stem}_{compteur}") + if not candidat.exists(): + return candidat + compteur += 1 + + +def trier_dossier(dossier: Path, executer: bool = False) -> dict[str, int]: + """Trie les fichiers de dossier. Renvoie le nombre de fichiers par catégorie.""" + bilan: dict[str, int] = {} + for fichier in sorted(dossier.iterdir()): + if not fichier.is_file() or fichier.name.startswith("."): + continue # on ignore les sous-dossiers et les fichiers cachés + categorie = trouver_categorie(fichier) + destination = destination_libre(dossier / categorie / fichier.name) + if executer: + destination.parent.mkdir(exist_ok=True) + shutil.move(fichier, destination) + action = "Déplacé" if executer else "[simulation]" + print(f"{action} {fichier.name} → {categorie}/") + bilan[categorie] = bilan.get(categorie, 0) + 1 + return bilan + + +def main() -> None: + analyseur = argparse.ArgumentParser(description="Range un dossier par type.") + analyseur.add_argument("dossier", type=Path, help="dossier à ranger") + analyseur.add_argument( + "--executer", action="store_true", help="déplace vraiment les fichiers" + ) + arguments = analyseur.parse_args() + + if not arguments.dossier.is_dir(): + analyseur.error(f"{arguments.dossier} n'est pas un dossier.") + + bilan = trier_dossier(arguments.dossier, arguments.executer) + print("\nBilan :") + for categorie, nombre in sorted(bilan.items()): + print(f" {categorie:<10} {nombre} fichier(s)") + + +if __name__ == "__main__": + main() +``` + +**Essaie-le sur un dossier de test**, jamais directement sur tes vrais fichiers la première fois : + +```bash +mkdir bazar +# crée quelques fichiers vides pour tester (macOS / Linux) : +touch bazar/vacances.JPG bazar/cv.pdf bazar/chanson.mp3 bazar/script.py bazar/archive.zip bazar/notes.txt bazar/mystere.xyz +``` + +```powershell +# Windows PowerShell : +mkdir bazar +"vacances.JPG","cv.pdf","chanson.mp3","script.py","archive.zip","notes.txt","mystere.xyz" | ForEach-Object { New-Item "bazar\$_" } +``` + +```bash +python trier_fichiers.py bazar # simulation +python trier_fichiers.py bazar --executer # pour de vrai +python trier_fichiers.py --help # aide générée automatiquement par argparse +``` + +```text +[simulation] archive.zip → Archives/ +[simulation] chanson.mp3 → Audio/ +[simulation] cv.pdf → Documents/ +[simulation] mystere.xyz → Autres/ +[simulation] notes.txt → Documents/ +[simulation] script.py → Code/ +[simulation] vacances.JPG → Images/ + +Bilan : + Archives 1 fichier(s) + Audio 1 fichier(s) + Autres 1 fichier(s) + Code 1 fichier(s) + Documents 2 fichier(s) + Images 1 fichier(s) +``` + +**Ce qu'il faut remarquer :** + +- `CATEGORIES` est un dictionnaire d'**ensembles** ([chapitre 04](04-structures-de-donnees.md)) : la recherche `extension in extensions` est instantanée. Pour ajouter un type, tu modifies **les données**, pas le code. +- `fichier.suffix.lower()` : `.JPG` et `.jpg` sont traités pareil. +- `destination_libre` évite d'**écraser** un fichier existant (`photo.jpg` devient `photo_1.jpg`). Avec l'automatisation, une erreur se répète sur des centaines de fichiers : sois prudent ! +- `argparse` transforme ton script en véritable **outil en ligne de commande**, avec une aide `--help` gratuite. +- Les fonctions `trouver_categorie` et `trier_dossier` sont faciles à tester avec pytest et `tmp_path` ([chapitre 13](13-tests-pytest-bonnes-pratiques.md)). + +> ⚠️ **Les trois règles de l'automatisation sereine** : 1) toujours un mode simulation ; 2) tester sur une copie ; 3) ne jamais supprimer quand on peut déplacer. + +#### 1.4 Aller plus loin en automatisation + +1. **Fichiers** : renommage en lot, sauvegarde datée d'un dossier dans un `.zip` (`shutil.make_archive`), recherche de doublons (en comparant des empreintes avec `hashlib`). +2. **Bureautique** : fusionner des fichiers Excel avec `openpyxl` (ou pandas), assembler des PDF avec `pypdf`, redimensionner des photos avec `Pillow`. +3. **Web** : télécharger des données avec `requests`, interroger des API publiques (météo, transports…), extraire des informations avec `BeautifulSoup`. Respecte les conditions d'utilisation des sites et ne les surcharge pas de requêtes. +4. **Planification** : lancer tes scripts automatiquement avec le Planificateur de tâches (Windows), `cron` (macOS/Linux) ou la bibliothèque `schedule`. +5. **Lecture recommandée** : le livre *Automate the Boring Stuff with Python* d'Al Sweigart (gratuit en ligne, en anglais) est la référence du domaine. + +### 2. Web : créer des sites et des API + +#### 2.1 À quoi ça sert ? + +Quand tu ouvres une page web, ton navigateur (le **client**) envoie une **requête HTTP** à un **serveur**, qui renvoie une **réponse** : une page HTML, une image, ou des données au format JSON. + +```text + Navigateur Serveur Python + ───────── ────────────── + GET /api/taches ───────▶ cherche les tâches + ◀─────── 200 OK [{"id": 1, "titre": "..."}] +``` + +Python est très utilisé **côté serveur** (le *back-end*) : Instagram, Pinterest ou de nombreux services de grandes entreprises reposent sur Python. Deux types de projets : + +- un **site web** : le serveur renvoie des pages HTML que l'humain lit dans son navigateur ; +- une **API** (*Application Programming Interface*) : le serveur renvoie des **données** (JSON) que d'autres programmes utilisent (une application mobile, un site en JavaScript, un autre script Python…). + +Vocabulaire indispensable : + +| Terme | Signification | +|---|---| +| **Route** (ou *endpoint*) | une adresse du site, comme `/`, `/contact`, `/api/taches/3` | +| **Méthode HTTP** | l'intention : `GET` (lire), `POST` (créer), `PUT`/`PATCH` (modifier), `DELETE` (supprimer) | +| **Code de statut** | le résultat : `200` OK, `201` créé, `404` introuvable, `422` données invalides, `500` erreur du serveur | +| **JSON** | le format de données du web, très proche des dictionnaires Python ([chapitre 07](07-fichiers-csv-json.md)) | +| **Template** | un modèle HTML avec des « trous » remplis par Python | + +#### 2.2 Bibliothèques clés + +| Framework | Points forts | Pour qui ? | +|---|---|---| +| **Flask** | minimaliste, simple, très bien documenté | premier site, petits projets, apprendre les bases | +| **FastAPI** | moderne, rapide, validation automatique, documentation d'API générée | API, projets qui utilisent les annotations de type ([chapitre 11](11-poo-avancee-heritage-typage.md)) | +| **Django** | « piles incluses » : base de données, comptes utilisateurs, interface d'administration | gros sites complets | + +Autour : `Jinja2` (templates, utilisé par Flask), `SQLAlchemy` ou l'ORM de Django (bases de données), `pydantic` (validation, utilisé par FastAPI), `requests`/`httpx` (appeler des API). + +#### 2.3 Exemple complet 1 : un site de citations avec Flask + +```bash +pip install flask +``` + +Enregistre `app_flask.py` : + +```python +"""Une mini application web de citations avec Flask.""" + +import random + +from flask import Flask, jsonify, render_template_string, request + +app = Flask(__name__) + +CITATIONS = [ + {"auteur": "Victor Hugo", "texte": "La musique, c'est du bruit qui pense."}, + { + "auteur": "Marie Curie", + "texte": "Rien dans la vie n'est à craindre, tout est à comprendre.", + }, + { + "auteur": "Albert Camus", + "texte": "Au milieu de l'hiver, j'ai découvert en moi un invincible été.", + }, +] + +PAGE = """ + + + Citation du jour + +

💬 Citation du jour

+
« {{ citation.texte }} »
+

— {{ citation.auteur }}

+

Une autre ! · Version JSON

+

Proposer une citation

+
+ + + +
+ + +""" + + +@app.route("/") +def accueil(): + return render_template_string(PAGE, citation=random.choice(CITATIONS)) + + +@app.route("/api/citations") +def api_citations(): + return jsonify(CITATIONS) + + +@app.route("/ajouter", methods=["POST"]) +def ajouter(): + CITATIONS.append({"auteur": request.form["auteur"], "texte": request.form["texte"]}) + return render_template_string(PAGE, citation=CITATIONS[-1]) + + +if __name__ == "__main__": + app.run(debug=True) +``` + +Lance le serveur : + +```bash +python app_flask.py +``` + +```text + * Running on http://127.0.0.1:5000 + * Debugger is active! +``` + +Ouvre **http://127.0.0.1:5000** dans ton navigateur. Rafraîchis : la citation change. Clique sur « Version JSON » : tu vois les données brutes. Ajoute une citation avec le formulaire. Pour arrêter le serveur : `Ctrl+C` dans le terminal. + +**Décryptage :** + +- `@app.route("/")` est un **décorateur** : il associe l'adresse `/` à la fonction `accueil`. Quand le navigateur demande `/`, Flask appelle `accueil()` et renvoie ce qu'elle retourne. +- `{{ citation.texte }}` est un « trou » du **template** Jinja2, rempli par `render_template_string(PAGE, citation=...)`. Jinja2 **échappe** automatiquement les caractères spéciaux : si quelqu'un tape `