Ressources PYA

TP : Comparaison de deux listes

set1 = {1, 2, 3, 4}
set2 = {3, 4, 5, 6}

# Intersection (elements commun)
intersection = set1 & set2  # Output: {3, 4}

# Union (tout les élements uniques)
union = set1 | set2  # Output: {1, 2, 3, 4, 5, 6}

# Difference (différences dans le set1 mais pas dans le set2)
difference = set1 - set2  # Output: {1, 2}

# Difference Symétrique (soit dans le set1 soit dans le set2)
symmetric_difference = set1 ^ set2  # Output: {1, 2, 5, 6}

Example : Getter Setter

from email.utils import parseaddr

class Exemple:
    def __init__(self, eml):
        self._email = eml
    @property
    def email(self):
        return self._email

    @email.setter
    def email(self, new_email):
        (_, parsed_email) = parseaddr(new_email)
        if '@' not in parsed_email:
            raise BaseException("error email not valid")
        self._email = parsed_email
        
test = Exemple("not set")
print(test.email)

test.email = "Super toto <toto@toto.com>"
print(test.email)

TP : Ensemble d'entrée

Créer un programme qui demande à l'utilisateur d'entrer des noms et de les ajouter à une liste. Implémentez des fonctionnalités pour :
Fonction : input()

  • Afficher tous les noms dans la liste.
  • Supprimer un nom donné par l'utilisateur.
  • Trier la liste par ordre alphabétique.
  • Compter le nombre d'occurrences d'un nom spécifique.
  • Rechercher si un nom donné est présent dans la liste.

TP : Mise en place d'une pile d'évènements

Sujet 1 : Créer un programme simulant une file d'attente pour un service client :

  • Utiliser une deque pour ajouter les clients à la file d'attente.
  • Offrir la possibilité de traiter le premier client dans la file (supprimer de la tête).
  • Ajouter une fonctionnalité pour ajouter un client prioritaire (ajouter à la tête de la deque).
  • Afficher la file d'attente actuelle après chaque opération.

Sujet 2 : Implémentez un système de gestion de navigation (comme un navigateur web simple) avec la possibilité d'aller en avant et en arrière dans l'historique :

    • Utilisez une deque pour stocker les pages visitées.
    • Permettez à l'utilisateur d'aller à une nouvelle page (ajouter à la deque).
    • Gérer la navigation arrière et avant avec des méthodes spécifiques de la deque.
    • Revenir a un moment spécifique de la navigation (par exemple 4 page en arrière)
from collections import deque

class NavigationHistory:
    def __init__(self, homepage="home"):
        self.back_stack = deque()
        self.forward_stack = deque()
        self.current = homepage

    def go_to(self, page: str):
        """Aller vers une nouvelle page."""
        self.back_stack.append(self.current)
        self.current = page
        self.forward_stack.clear()  # On quitte le chemin "avant"
        print(f"→ Visite : {self.current}")

    def back(self):
        """Revenir d’une page en arrière."""
        if not self.back_stack:
            print("Impossible : aucune page avant.")
            return
        self.forward_stack.appendleft(self.current)
        self.current = self.back_stack.pop()
        print(f"Retour arrière : {self.current}")

    def forward(self):
        """Aller d’une page en avant."""
        if not self.forward_stack:
            print("Impossible : aucune page après.")
            return
        self.back_stack.append(self.current)
        self.current = self.forward_stack.popleft()
        print(f"→ Navigation avant : {self.current}")

    def back_n(self, n: int):
        """Revenir n pages en arrière."""
        print(f"Retour de {n} pages")
        for _ in range(n):
            if not self.back_stack:
                print("Limite atteinte")
                break
            self.back()
    
    def status(self):
        print("======== ÉTAT ACTUEL ========")
        print("Page courante :", self.current)
        print("Historique arrière :", list(self.back_stack))
        print("Historique avant :", list(self.forward_stack))
        print("================================\n")

TP : Mise en pratique dict

  • Créer un programme qui analyse un texte et calcule la fréquence d'apparition de chaque mot :
    • Lire un texte fourni par l'utilisateur.
    • Stocker chaque mot comme clé d'un dictionnaire et le nombre d'apparitions comme valeur.
    • Afficher les mots les plus fréquents.
from collections import Counter
import string

def analyser_texte():
    texte = input("Entrez votre texte : ")

    # Nettoyer la ponctuation
    for p in string.punctuation:
        texte = texte.replace(p, " ")

    mots = texte.lower().split()

    # Counter fait tout le travail
    freq = Counter(mots)

    print("\nMots les plus fréquents :")
    for mot, nb in freq.most_common(5):   # tri automatique par fréquence (5 premier)
        print(f"{mot} : {nb}")

analyser_texte()
  • Implémentez un carnet d'adresses utilisant un dict :
    • Chaque contact a un nom comme clé et un numéro de téléphone comme valeur.
    • Permettre l'ajout, la modification, et la suppression de contacts.
    • Rechercher un contact par son nom.
    • Afficher tous les contacts dans l'ordre alphabétique.
    • Supression des contacts en double
class CarnetAdresses:
    def __init__(self):
        # Le carnet est un dictionnaire : {nom: téléphone}
        self.contacts = {}

    def ajouter(self, nom, tel):
        self.contacts[nom] = tel
        print(f"Contact ajouté : {nom} -> {tel}")

    def modifier(self, nom, tel):
        if nom in self.contacts:
            self.contacts[nom] = tel
            print(f"Contact modifié : {nom} -> {tel}")
        else:
            print("Contact introuvable.")

    def supprimer(self, nom):
        if nom in self.contacts:
            del self.contacts[nom]
            print(f"Contact supprimé : {nom}")
        else:
            print("Contact introuvable.")

    def rechercher(self, nom):
        if nom in self.contacts:
            print(f"{nom} : {self.contacts[nom]}")
        else:
            print("Contact introuvable.")

    def afficher_tous(self):
        print("\nContacts (alphabétique) :")
        for nom in sorted(self.contacts.keys()):
            print(f"{nom} : {self.contacts[nom]}")

    def supprimer_doublons(self):
        inverse = {}
        doublons = []

        for nom, tel in self.contacts.items():
            if tel not in inverse:
                inverse[tel] = nom
            else:
                doublons.append(nom)

        for nom in doublons:
            del self.contacts[nom]

        print(f"{len(doublons)} doublon(s) supprimé(s).")


def menu():
    carnet = CarnetAdresses()

    while True:
        print("\n===== CARNET D'ADRESSES =====")
        print("1. Ajouter un contact")
        print("2. Modifier un contact")
        print("3. Supprimer un contact")
        print("4. Rechercher un contact")
        print("5. Afficher tous les contacts")
        print("6. Supprimer les doublons")
        print("0. Quitter")

        choix = input("Votre choix : ")

        if choix == '1':
            nom = input("Nom : ")
            tel = input("Téléphone : ")
            carnet.ajouter(nom, tel)

        elif choix == '2':
            nom = input("Nom : ")
            tel = input("Nouveau téléphone : ")
            carnet.modifier(nom, tel)

        elif choix == '3':
            nom = input("Nom : ")
            carnet.supprimer(nom)

        elif choix == '4':
            nom = input("Nom : ")
            carnet.rechercher(nom)

        elif choix == '5':
            carnet.afficher_tous()

        elif choix == '6':
            carnet.supprimer_doublons()

        elif choix == '0':
            break

        else:
            print("Choix invalide.")


menu()

TP : Mise en pratique globale

Créer une application de gestion de bibliothèque :

  • Utiliser un list pour stocker les livres disponibles.
  • Un set pour suivre les genres de livres uniques dans la bibliothèque.
  • Un deque pour gérer les emprunts et les retours de livres (les premiers empruntés doivent être les premiers rendus).
  • Un dict pour gérer les informations des livres (titre, auteur, année, genre, nombre de livres).
  • Un defaultdict(list) pour regrouper les livres par auteur.

Règles à implémenter :

  • L'ajout d'un livre contenant un genre non existant est interdit
  • Un livre peut avoir plusieurs occurrences (e.g. deux livre avec le même titre)
  • Les livres sont référencés par leur titre
  • Un livre ne peut pas être emprunté plus de fois que d'exemplaire disponible (à vous de trouver le format de donnée pour l'implémentation)
  • Un livre peut être rendu
  • On doit pouvoir retrouver les livres par leur auteur

Optionnel

  • On peut rendre les livres dans le désordre
  • Si on supprime un genre tous les livres du genre sont supprimés
  • Si on supprime un auteur tous les livres de l'auteur sont supprimés

TP Finaux

TP 1 : Manipulation de fichier

Partie 1 : Manipulation d’un Fichier CSV

1. Création d’un fichier CSV

Crée un fichier produits.csv contenant les données suivantes (avec un script python) :

IDNom du ProduitCatégoriePrix (€)Stock
1OrdinateurInformatique100010
2SourisInformatique2050
3ClavierInformatique3040
4TéléphoneTéléphonie50025
5Casque AudioAudio10015

Solution

import csv

ID = "ID"
NOM = "Nom du Produit"
CATEGORIE = "Catégorie"
PRIX = "Prix (€)"
STOCK = "Stock"

# Création du fichier CSV
with open('produits.csv', 'w', newline='', encoding='utf-8') as fichier_csv:
    writer = csv.writer(fichier_csv)
    # Écriture de l'en-tête
    writer.writerow(['ID', 'Nom du Produit', 'Catégorie', 'Prix (€)', 'Stock'])
    # Écriture des données
    writer.writerows([
        [1, 'Ordinateur', 'Informatique', 1000, 10],
        [2, 'Souris', 'Informatique', 20, 50],
        [3, 'Clavier', 'Informatique', 30, 40],
        [4, 'Téléphone', 'Téléphonie', 500, 25],
        [5, 'Casque Audio', 'Audio', 100, 15],
    ])

  1. Lire et afficher le fichier en python

Solution

# Lecture du fichier CSV
with open('produits.csv', 'r', encoding='utf-8') as fichier_csv:
    reader = csv.reader(fichier_csv)
    for ligne in reader:
        print(ligne)

Partie 2 : Manipulation d’un Fichier JSON

1. Conversion du CSV en JSON

Écrire un script qui lit le fichier produits.csv et le convertit en fichier JSON nommé produits.json.

Solution

import json

# Lecture du fichier CSV et conversion en liste de dictionnaires
produits = []
with open('produits.csv', 'r', encoding='utf-8') as fichier_csv:
    reader = csv.DictReader(fichier_csv)
    for ligne in reader:
        produits.append(ligne)

# Écriture dans un fichier JSON
with open('produits.json', 'w', encoding='utf-8') as fichier_json:
    json.dump(produits, fichier_json, indent=4, ensure_ascii=False)

2. Lecture et Modification du fichier JSON

Modifier le prix de tous les produits en appliquant une réduction de 10 %.

Solution

# Lecture du fichier JSON
with open('produits.json', 'r', encoding='utf-8') as fichier_json:
    produits = json.load(fichier_json)

# Application de la réduction de 10 % sur les prix
for produit in produits:
    produit[PRIX] = round(float(produit[PRIX]) * 0.9, 2) # Prix est sur la position 3

# Écriture des modifications dans le fichier JSON
with open('produits.json', 'w', encoding='utf-8') as fichier_json:
    json.dump(produits, fichier_json, indent=4, ensure_ascii=False)

Partie 3 : Extraction et Analyse des Données

Extraction : Créer une liste des produits dont le stock est inférieur à 20.
Analyse : Afficher la somme totale des stocks.

Solution

# Extraction des produits avec un stock inférieur à 20
produits_stock_limite = [produit for produit in produits if int(produit[STOCK]) < 20]

print("Produits avec stock inférieur à 20 :")
for produit in produits_stock_limite:
    print(produit[NOM], "-", produit[STOCK])

# Calcul de la somme totale des stocks
stock_total = sum(int(produit[STOCK]) for produit in produits)
print(f"Stock total : {stock_total}")

Bonus : Exportation des Données Modifiées en CSV

Recréer un fichier CSV à partir des données modifiées du JSON.

Solution

# Écriture du fichier CSV à partir des données JSON
with open('produits_modifies.csv', 'w', newline='', encoding='utf-8') as fichier_csv:
    writer = csv.DictWriter(fichier_csv, fieldnames=produits[0].keys())
    writer.writeheader()
    writer.writerows(produits)

TP 2 : Programmation Réseau avec Python

Partie 1 : Création d’un Serveur TCP

Le serveur doit écouter sur une adresse IP locale et un port spécifique, recevoir des messages d’un client, et répondre avec une confirmation.

Solution

import socket

# Création du serveur TCP
serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.bind(('127.0.0.1', 12345))
serveur.listen(1)
print("Serveur en attente de connexion...")

connexion, adresse = serveur.accept()
print(f"Connecté à {adresse}")

# Boucle pour recevoir et répondre aux messages
while True:
    message = connexion.recv(1024).decode('utf-8')
    if not message or message.lower() == 'quit\r\n':
        print("Fermeture de la connexion.")
        break
    print(f"Message reçu : {message}")
    connexion.sendall("Message reçu".encode('utf-8'))

connexion.close()
serveur.close()

Partie 2 : Création du client TCP

Le client doit se connecter au serveur, envoyer un message, puis afficher la réponse du serveur.

Solution

import socket

# Création du client TCP
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('127.0.0.1', 12345))

# Envoi d'un message
message = input("Entrez un message à envoyer au serveur : ")
client.sendall(message.encode('utf-8'))

# Réception de la réponse
reponse = client.recv(1024).decode('utf-8')
print(f"Réponse du serveur : {reponse}")

client.close()

Partie 3 : Communication UDP

  1. Le serveur UDP

Solution

Le serveur UDP doit écouter sur un port et répondre à chaque message reçu.

import socket

# Création du serveur UDP
serveur = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
serveur.bind(('127.0.0.1', 12345))

print("Serveur UDP en attente de messages...")

while True:
    message, adresse = serveur.recvfrom(1024)
    print(f"Message reçu de {adresse} : {message.decode('utf-8')}")
    serveur.sendto("Message bien reçu".encode('utf-8'), adresse)
  1. Le client UDP

Le client UDP envoie un message et attend la réponse du serveur.

Solution

import socket

# Création du client UDP
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

message = input("Entrez un message à envoyer au serveur : ")
client.sendto(message.encode('utf-8'), ('127.0.0.1', 12345))

reponse, _ = client.recvfrom(1024)
print(f"Réponse du serveur : {reponse.decode('utf-8')}")

client.close()

Partie 4 : Analyse Simple des Réseaux

  1. Récupération d’une Adresse IP depuis un Nom de Domaine

Écrire un script qui demande à l’utilisateur un nom de domaine et affiche son adresse IP.

Solution

import socket

nom_domaine = input("Entrez un nom de domaine : ")
adresse_ip = socket.gethostbyname(nom_domaine)
print(f"L'adresse IP de {nom_domaine} est : {adresse_ip}")
  1. Vérification de Ports Ouverts

Écrire un script qui scanne les ports d’une machine locale pour vérifier s’ils sont ouverts.

Solution

import socket

hote = '127.0.0.1'
ports_a_verifier = [22, 80, 443, 12345]

for port in ports_a_verifier:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        resultat = s.connect_ex((hote, port))
        if resultat == 0:
            print(f"Port {port} : OUVERT")
        else:
            print(f"Port {port} : FERMÉ")

Partie 5 : Serveur Multi-Clients (Bonus)

Modifier le serveur TCP pour gérer plusieurs connexions en parallèle avec des threads.

Solution

import socket
import threading

def gerer_client(connexion, adresse):
    print(f"Nouvelle connexion : {adresse}")
    while True:
        message = connexion.recv(1024).decode('utf-8')
        if not message or message.lower() == 'quit':
            break
        print(f"Message de {adresse} : {message}")
        connexion.sendall("Message reçu".encode('utf-8'))
    connexion.close()
    print(f"Connexion fermée : {adresse}")

serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.bind(('127.0.0.1', 12345))
serveur.listen(5)
print("Serveur en attente de connexions...")

while True:
    connexion, adresse = serveur.accept()
    thread = threading.Thread(target=gerer_client, args=(connexion, adresse))
    thread.start()

TP 3 : Introduction IA avec tensorflow

Étape 1 : Chargement des données

  • TensorFlow propose des datasets intégrés. Utilisez le dataset MNIST (chiffres manuscrits).
# Install: pip install tensorflow-datasets
import tensorflow as tf
import tensorflow_datasets as tfds
mnist_data = tfds.load("mnist")
# Charger les données et les séparer en ensembles d'entraînement et de test
mnist_train, mnist_test = mnist_data["train"], mnist_data["test"]

batch_size = 32

train_size = mnist_train.cardinality().numpy() * batch_size
test_size = mnist_test.cardinality().numpy() * batch_size

print(f"Taille du jeu d'entraînement : {train_size} échantillons")
print(f"Taille du jeu de test : {test_size} échantillons")

Étape 2 : Prétraitement des données

  1. Normalisez les images pour que les valeurs des pixels soient comprises entre 0 et 1.
  2. Transformez les labels (y) en vecteurs one-hot encodés.
def preprocess(features):
    image = features["image"]
    label = features["label"]
    image = tf.cast(image, tf.float32) / 255.0  # Normalize to [0, 1]
    return image, label

# Prepare the training and test datasets
batch_size = 32
mnist_train = mnist_train.map(preprocess).shuffle(10000).batch(batch_size)
mnist_test = mnist_test.map(preprocess).batch(batch_size)

Étape 3 : Création du modèle

  • Créez un modèle simple avec une couche d'entrée flatten, une couche cachée dense avec activation ReLU, et une couche de sortie softmax.
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Flatten, Dense

model = Sequential([
    Flatten(input_shape=(28, 28, 1)),
    Dense(128, activation='relu'),
    Dense(10, activation='softmax')
])

Étape 4 : Compilation et entraînement

  1. Compilez le modèle avec une fonction de perte categorical_crossentropy, un optimiseur adam, et mesurez l’accuracy.
  2. Entraînez le modèle sur les données d’entraînement avec validation sur les données de test.
model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

model.fit(mnist_train, epochs=5)

Étape 5 : Évaluation du modèle

  1. Évaluez la performance du modèle sur le jeu de test.
  2. Affichez les résultats et tracez les courbes de perte et d’accuracy.
test_loss, test_acc = model.evaluate(mnist_test)
print(f"Précision sur le jeu de test : {test_acc:.2f}")

import matplotlib.pyplot as plt

# Evaluate the model
test_loss, test_acc = model.evaluate(mnist_test)

# Plot the results
metrics = ['Test Loss', 'Test Accuracy']
values = [test_loss, test_acc]

# Create a bar chart
plt.figure(figsize=(8, 5))
plt.bar(metrics, values, color=['skyblue', 'lightgreen'])
plt.ylim(0, 1)  # Accuracy is between 0 and 1, so we set limits for better visualization
plt.title('Model Evaluation Metrics')
plt.ylabel('Value')
plt.text(0, values[0] + 0.02, f'{values[0]:.4f}', ha='center')
plt.text(1, values[1] + 0.02, f'{values[1]:.4f}', ha='center')

# Display the plot
plt.show()

TP 4 : Utilisation de Docker + Python (automatisation)

pip install docker

Partie 1 : Automatisation du Démarrage et de l’Arrêt des Conteneurs

1. Script Python pour Démarrer un Conteneur Docker
Le script suivant démarre un conteneur à partir d’une image donnée (par exemple, nginx).

Solution

import docker

# Connexion au daemon Docker
client = docker.from_env()

def demarrer_conteneur(image_name):
    try:
        conteneur = client.containers.run(image_name, detach=True, name="mon_conteneur")
        print(f"Conteneur démarré : {conteneur.short_id}")
    except docker.errors.APIError as e:
        print(f"Erreur lors du démarrage : {e}")

# Démarrer un conteneur nginx
demarrer_conteneur("nginx")

2. Script Python pour Arrêter et Supprimer un Conteneur
Le script suivant arrête et supprime le conteneur démarré précédemment.

Solution

def arreter_et_supprimer_conteneur(container_name):
    try:
        conteneur = client.containers.get(container_name)
        conteneur.stop()
        conteneur.remove()
        print(f"Conteneur arrêté et supprimé : {container_name}")
    except docker.errors.NotFound:
        print(f"Conteneur {container_name} introuvable.")
    except docker.errors.APIError as e:
        print(f"Erreur : {e}")

# Arrêter et supprimer le conteneur nommé "mon_conteneur"
arreter_et_supprimer_conteneur("mon_conteneur")

Partie 2 : Gestion des Logs des Conteneurs

1. Affichage des Logs d’un Conteneur en Temps Réel

Ce script affiche les logs en continu pour un conteneur spécifique.

Solution

def afficher_logs(container_name):
    try:
        conteneur = client.containers.get(container_name)
        for ligne in conteneur.logs(stream=True):
            print(ligne.decode('utf-8').strip())
    except docker.errors.NotFound:
        print(f"Conteneur {container_name} introuvable.")

# Afficher les logs du conteneur nommé "mon_conteneur"
afficher_logs("mon_conteneur")

2. Sauvegarde des Logs dans un Fichier

Le script suivant enregistre les logs d’un conteneur dans un fichier.

Solution

def sauvegarder_logs(container_name, fichier_log):
    try:
        conteneur = client.containers.get(container_name)
        with open(fichier_log, 'w') as fichier:
            fichier.write(conteneur.logs().decode('utf-8'))
        print(f"Logs sauvegardés dans {fichier_log}.")
    except docker.errors.NotFound:
        print(f"Conteneur {container_name} introuvable.")

# Sauvegarder les logs du conteneur "mon_conteneur" dans un fichier
sauvegarder_logs("mon_conteneur", "logs_conteneur.txt")

Partie 3 : Automatisation Avancée

1. Automatiser le Démarrage de Plusieurs Conteneurs

Démarre plusieurs conteneurs à partir d’une liste d’images.

def demarrer_conteneurs(images):
    for image in images:
        demarrer_conteneur(image)

# Démarrer des conteneurs pour plusieurs images
images = ["nginx", "redis", "alpine"]
demarrer_conteneurs(images)
  1. Nettoyage Automatique des Conteneurs Arrêtés

Ce script supprime tous les conteneurs arrêtés.

def nettoyage_conteneurs_arretes():
    conteneurs = client.containers.list(all=True)
    for conteneur in conteneurs:
        if conteneur.status == 'exited':
            print(f"Suppression du conteneur : {conteneur.name}")
            conteneur.remove()

# Nettoyage des conteneurs arrêtés
nettoyage_conteneurs_arretes()

Partie 4 : Créer un Service Automatisé avec systemd

1. Fichier de Service systemd

Crée un fichier /etc/systemd/system/gestion_docker.service :

[Unit]
Description=Service d'automatisation Docker
After=docker.service

[Service]
ExecStart=/usr/bin/python3 /chemin/vers/script_docker.py
Restart=always

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable gestion_docker.service
sudo systemctl start gestion_docker.service

Calcul Scientifique avec Python

pip install numpy scipy matplotlib

Partie 1 : Manipulation des Matrices avec NumPy

1. Création et Opérations sur les Matrices

Crée deux matrices et effectue des opérations basiques comme l'addition, la multiplication, et le calcul du déterminant.

import numpy as np

# Création de matrices
A = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
B = np.array([[9, 8, 7], [6, 5, 4], [3, 2, 1]])

# Addition
C = A + B
print("Addition des matrices :\n", C)

# Multiplication élément par élément
D = A * B
print("Multiplication élément par élément :\n", D)

# Produit matriciel
E = np.dot(A, B)
print("Produit matriciel :\n", E)

# Calcul du déterminant
det_A = np.linalg.det(A)
print("Déterminant de A :", det_A)

2. Résolution d’un Système Linéaire

Résolvons le système d’équations suivant :

2x+y−z=8

−3x−y+2z=−11

−2x+y+2z=−3

# Matrice des coefficients
coefficients = np.array([[2, 1, -1], [-3, -1, 2], [-2, 1, 2]])

# Matrice des constantes
constantes = np.array([8, -11, -3])

# Résolution du système
solution = np.linalg.solve(coefficients, constantes)
print("Solution du système : x = {}, y = {}, z = {}".format(*solution))

Partie 2 : Calculs Avancés avec SciPy

1. Calcul d’Intégrale

Calculons l’intégrale de la fonction f(x) = x^2 entre 0 et 4.

from scipy.integrate import quad

# Définition de la fonction
def f(x):
    return x**2

# Calcul de l'intégrale
resultat, erreur = quad(f, 0, 4)
print("Résultat de l'intégrale :", resultat)
2. Résolution d’une Équation Différentielle

Résolvons l’équation différentielle suivante :

dy / dt ​=−2y ,y(0)=1

from scipy.integrate import solve_ivp

# Définition de l'équation différentielle
def equation(t, y):
    return -2 * y

# Résolution de l'équation
solution = solve_ivp(equation, [0, 5], [1], t_eval=np.linspace(0, 5, 100))
print("Solution :\n", solution.y[0])

Partie 3 : Visualisation des Données avec Matplotlib

1. Tracé d’une Fonction

Traçons la fonction f(x)=sin⁡(x)

import matplotlib.pyplot as plt

# Définition des données
x = np.linspace(0, 2 * np.pi, 100)
y = np.sin(x)

# Tracé
plt.plot(x, y, label='sin(x)')
plt.title('Tracé de sin(x)')
plt.xlabel('x')
plt.ylabel('sin(x)')
plt.legend()
plt.grid(True)
plt.show()
2. Visualisation des Solutions de l’Équation Différentielle

Représentons graphiquement la solution obtenue précédemment.

# Tracé de la solution
plt.plot(solution.t, solution.y[0], label='Solution de dy/dt = -2y')
plt.title('Solution de l\'équation différentielle')
plt.xlabel('Temps (t)')
plt.ylabel('y(t)')
plt.legend()
plt.grid(True)
plt.show()

Partie 4 : Analyse Statistique

1. Calcul de Statistiques de Base

Analyse des données suivantes : [10, 20, 30, 40, 50].

# Données
donnees = np.array([10, 20, 30, 40, 50])

# Calcul des statistiques
moyenne = np.mean(donnees)
mediane = np.median(donnees)
ecart_type = np.std(donnees)

print(f"Moyenne : {moyenne}, Médiane : {mediane}, Écart-type : {ecart_type}")

TP : Connexion avec la base de donnée MYSQL

pip install mysql-connector-python

Partie 1 : Connexion à une Base de Données MySQL

Établissons une connexion à une base de données MySQL locale.

import mysql.connector

# Connexion à MySQL
conn = mysql.connector.connect(
    host="localhost",
    user="root",  # Remplacez par votre nom d'utilisateur
    password="password"  # Remplacez par votre mot de passe
)

if conn.is_connected():
    print("Connexion réussie à MySQL")
else:
    print("Échec de la connexion")

Partie 2 : Création d'une Base de Données et d'une Table

Créons une base de données nommée tp_mysql et une table etudiants.

# Création d'un curseur
cursor = conn.cursor()

# Création de la base de données
cursor.execute("CREATE DATABASE IF NOT EXISTS tp_mysql")

# Connexion à la base de données créée
conn.database = "tp_mysql"

# Création de la table
cursor.execute("""
CREATE TABLE IF NOT EXISTS etudiants (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nom VARCHAR(255),
    age INT,
    filiere VARCHAR(255)
)
""")
print("Table 'etudiants' créée.")

Partie 3 : Insertion de Données

Ajoutons des enregistrements dans la table etudiants.

# Requête d'insertion
sql = "INSERT INTO etudiants (nom, age, filiere) VALUES (%s, %s, %s)"
valeurs = [
    ("Alice", 20, "Informatique"),
    ("Bob", 22, "Mathématiques"),
    ("Charlie", 21, "Physique")
]

# Exécution de la requête
cursor.executemany(sql, valeurs)
conn.commit()
print(f"{cursor.rowcount} enregistrements insérés.")

Partie 4 : Lecture des Données

Récupérons les données de la table etudiants.

# Requête de sélection
cursor.execute("SELECT * FROM etudiants")
resultats = cursor.fetchall()

# Affichage des résultats
for etudiant in resultats:
    print(etudiant)

Partie 5 : Mise à Jour des Données

Modifions l'age d'un étudiant

# Requête de mise à jour
sql = "UPDATE etudiants SET age = %s WHERE nom = %s"
valeurs = (23, "Alice")

# Exécution de la requête
cursor.execute(sql, valeurs)
conn.commit()
print(f"{cursor.rowcount} enregistrement(s) mis à jour.")

Partie 6 : Suppression de Données

Supprimons un étudiant de la table.

# Requête de suppression
sql = "DELETE FROM etudiants WHERE nom = %s"
valeurs = ("Bob",)

# Exécution de la requête
cursor.execute(sql, valeurs)
conn.commit()
print(f"{cursor.rowcount} enregistrement(s) supprimé(s).")

Partie 7 : Gestion des Erreurs et Fermeture de la Connexion

Assurons-nous de gérer les erreurs et de fermer proprement la connexion.

# Gestion des erreurs
try:
    cursor.execute("SELECT * FROM etudiants")
except mysql.connector.Error as err:
    print(f"Erreur : {err}")

# Fermeture des connexions
cursor.close()
conn.close()
print("Connexion fermée.")

Exercices Complémentaires :

Ajoutez de nouvelles colonnes dans la table etudiants pour stocker des informations supplémentaires, comme l'adresse ou le numéro de téléphone.

Effectuez des requêtes filtrées pour récupérer uniquement les étudiants d’une certaine filière.

Ajoutez des contraintes comme des clés étrangères en créant une nouvelle table.

Map Reduce

# Sum of Square

# tasks.py
from celery import Celery

app = Celery('mapreduce', broker='redis://localhost:6379/0', backend='redis://localhost:6379/0')

@app.task
def square(n):
    return n * n

@app.task
def reduce_sum(values):
    return sum(values)

# main.py
from tasks import square, reduce_sum
from celery.result import GroupResult
from celery import group

def map_reduce(data):
    # Step 1: Map Phase (square each number)
    map_jobs = group(square.s(n) for n in data)()
    map_results = map_jobs.get()

    print("Mapped results:", map_results)

    # Step 2: Reduce Phase (sum the results)
    reduce_job = reduce_sum.delay(map_results)
    final_result = reduce_job.get()

    print("Reduced result:", final_result)
    return final_result

if __name__ == '__main__':
    numbers = list(range(1, 6))  # [1, 2, 3, 4, 5]
    result = map_reduce(numbers)
    print("Sum of squares:", result)
python -m celery -A tasks worker --pool=solo -l info

TP : gRPC python

Partie 1

Dans cette étape, vous concevez l’interface publique de votre microservice en utilisant le langage de description d’interface de Protocol Buffers (proto3). Vous définissez :

  • Les messages échangés : NoteRequest, StudentRequest, etc.
  • Les méthodes du service : AddNote, GetNotes, GetAverage.

Cela permet de générer automatiquement les classes nécessaires pour que le client et le serveur puissent communiquer.

grpc-notes/
├── client/
│   └── client.py
├── generated/
│   ├── notes_pb2.py
│   └── notes_pb2_grpc.py
├── proto/
│   └── notes.proto
├── server/
│   ├── notes_service.py
│   └── server.py
├── requirements.txt
└── README.md
syntax = "proto3";

package notes;

service NotesService {
  rpc AddNote (NoteRequest) returns (NoteResponse);
  rpc GetNotes (StudentRequest) returns (NotesList);
  rpc GetAverage (StudentRequest) returns (AverageResponse);
}

message NoteRequest {
  string student_id = 1;
  float note = 2;
}

message StudentRequest {
  string student_id = 1;
}

message NoteResponse {
  string message = 1;
}

message NotesList {
  repeated float notes = 1;
}

message AverageResponse {
  float average = 1;
}

proto/notes.proto

Vous installez les dépendances Python (grpcio, grpcio-tools) nécessaires au fonctionnement de gRPC en Python.
Ensuite, vous utilisez la commande protoc pour générer deux fichiers Python à partir de notes.proto :

  • notes_pb2.py : contient les définitions de messages.
  • notes_pb2_grpc.py : contient les stubs du service (interface client et base du serveur).

Ces fichiers sont utilisés pour écrire le serveur et le client.

grpcio
grpcio-tools

requirements.txt

python -m grpc_tools.protoc -Iproto --python_out=generated --grpc_python_out=generated proto/notes.proto

Partie 2

Vous implémentez une classe NotesService qui hérite du service gRPC généré (NotesServiceServicer). Cette classe contient :

  • une méthode pour ajouter une note,
  • une pour récupérer toutes les notes d’un étudiant,
  • une pour calculer la moyenne.

Les données sont stockées en mémoire dans un dictionnaire Python ({student_id: [notes]}).

Ensuite, vous configurez et démarrez le serveur avec grpc.server(), sur le port 50051, dans server.py.

from generated import notes_pb2, notes_pb2_grpc

class NotesService(notes_pb2_grpc.NotesServiceServicer):
    def __init__(self):
        self.notes = {}

    def AddNote(self, request, context):
        student_id = request.student_id
        note = request.note
        self.notes.setdefault(student_id, []).append(note)
        return notes_pb2.NoteResponse(message=f"Note {note} ajoutée pour {student_id}.")

    def GetNotes(self, request, context):
        student_id = request.student_id
        notes = self.notes.get(student_id, [])
        return notes_pb2.NotesList(notes=notes)

    def GetAverage(self, request, context):
        student_id = request.student_id
        notes = self.notes.get(student_id, [])
        if not notes:
            return notes_pb2.AverageResponse(average=0.0)
        avg = sum(notes) / len(notes)
        return notes_pb2.AverageResponse(average=avg)

server/notes_service.py

from concurrent import futures
import grpc
import time

from generated import notes_pb2_grpc
from server.notes_service import NotesService

def serve():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    notes_pb2_grpc.add_NotesServiceServicer_to_server(NotesService(), server)
    server.add_insecure_port('[::]:50051')
    server.start()
    print("Serveur gRPC lancé sur le port 50051.")
    try:
        while True:
            time.sleep(86400)
    except KeyboardInterrupt:
        print("Arrêt du serveur.")
        server.stop(0)

if __name__ == '__main__':
    serve()

server/server.py

Partie 3

Le client gRPC :

  • Établit une connexion avec le serveur (localhost:50051),
  • Utilise le stub généré pour envoyer des requêtes au serveur,
  • Affiche les réponses dans un menu interactif.

Trois fonctionnalités sont disponibles :

  1. Ajouter une note à un étudiant.
  2. Afficher toutes les notes d’un étudiant.
  3. Calculer la moyenne des notes d’un étudiant.

Le client permet de tester l’ensemble des fonctionnalités exposées par le serveur.

import grpc
from generated import notes_pb2, notes_pb2_grpc

def run():
    channel = grpc.insecure_channel('localhost:50051')
    stub = notes_pb2_grpc.NotesServiceStub(channel)

    while True:
        print("\n--- Menu ---")
        print("1. Ajouter une note")
        print("2. Voir les notes d’un étudiant")
        print("3. Calculer la moyenne")
        print("4. Quitter")
        choice = input("Votre choix : ")

        if choice == "1":
            sid = input("ID étudiant : ")
            note = float(input("Note : "))
            resp = stub.AddNote(notes_pb2.NoteRequest(student_id=sid, note=note))
            print(resp.message)

        elif choice == "2":
            sid = input("ID étudiant : ")
            resp = stub.GetNotes(notes_pb2.StudentRequest(student_id=sid))
            print("Notes :", resp.notes)

        elif choice == "3":
            sid = input("ID étudiant : ")
            resp = stub.GetAverage(notes_pb2.StudentRequest(student_id=sid))
            print("Moyenne :", resp.average)

        elif choice == "4":
            break
        else:
            print("Option invalide.")

if __name__ == '__main__':
    run()

client/client.py

Partie 4

Vous commencez par générer le code gRPC avec protoc.
Puis vous lancez le serveur (server.py) et, dans une autre console, le client (client.py).
Vous pouvez ensuite interagir avec le service via le menu du client. Chaque action déclenche une requête gRPC.

## Démarer le serveur
python server/server.py

## Démarer le client
python client/client.py

TP : Numba

Dans ce TP, vous découvrirez comment optimiser les performances d'un programme Python à l'aide de la bibliothèque Numba, un compilateur JIT (Just-In-Time) qui permet d’accélérer considérablement l'exécution de certaines fonctions, notamment celles impliquant des calculs intensifs ou des boucles lourdes.

À travers plusieurs exercices progressifs, vous comparerez le temps d'exécution de fonctions classiques en Python avec leurs équivalents optimisés par Numba. Vous utiliserez à la fois des décorateurs simples comme @njit et des fonctions traitant des tableaux NumPy. Le TP se termine par une mise en œuvre graphique d’un ensemble de Mandelbrot, montrant visuellement les avantages de Numba.

  • nopython=True
    • Active le mode "nopython", dans lequel tout le code est compilé en machine, sans tomber en mode Python. C’est le mode le plus rapide.
  • parallel=True
    • Active l'exécution parallèle automatique (multi-cœurs) lorsque c’est possible. Nécessite prange() dans les boucles.
  • cache=True
    • Active la mise en cache du code compilé pour réutilisation sans recompilation lors des prochains lancements.
  • nogil=True
    • Permet l’exécution sans verrou Python (GIL), utile dans un contexte multi-thread.
pip install numba numpy matplotlib

Structure attendue

numba-tp/
├── exercice_1_basique.py
├── exercice_2_numpy.py
├── exercice_3_fractale_mandelbrot.py
└── requirements.txt

Partie 1 :

Vous allez comparer deux fonctions qui calculent une factorielle de manière répétée :

  • Une fonction Python classique, volontairement lente.
  • Une version compilée avec Numba (@njit).

Vous mesurerez le temps d’exécution dans chaque cas afin de quantifier le gain de performance.

import time
from numba import jit

def slow_factorial(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

@jit(nopython=True)
def fast_factorial(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

N = 10_000

start = time.time()
for _ in range(1000):
    slow_factorial(500)
print("Sans Numba :", time.time() - start, "secondes")

start = time.time()
for _ in range(1000):
    fast_factorial(500)
print("Avec Numba  :", time.time() - start, "secondes")

Partie 2 :

Dans cette partie, vous allez appliquer une opération mathématique complexe (combinaison de puissance, sinus, logarithme) à un très grand tableau NumPy (10 millions d’éléments). Vous comparerez trois versions :

  • Traitement NumPy vectorisé seul,
  • Traitement Python avec une boucle,
  • Traitement Python + boucle optimisée avec Numba.
import numpy as np
import time
from numba import njit

@njit
def compute(data):
    result = np.empty_like(data)
    for i in range(data.shape[0]):
        result[i] = data[i] ** 1.5 + np.sin(data[i]) - np.log1p(data[i])
    return result

data = np.linspace(0.01, 100, 10_000_000)

# Version NumPy seule
start = time.time()
result_numpy = data ** 1.5 + np.sin(data) - np.log1p(data)
print("NumPy :", time.time() - start, "s")

# Version Numba
start = time.time()
result_numba = compute(data)
print("Numba  :", time.time() - start, "s")

Partie 3 :

Vous implémentez une version simple du générateur de l’ensemble de Mandelbrot. Ce type de fractale nécessite de nombreuses itérations sur chaque pixel de l’image. Sans Numba, l’affichage est lent. Avec Numba, vous observerez une nette amélioration des performances, rendant la génération interactive.

import numpy as np
import matplotlib.pyplot as plt
from numba import njit
import time

@njit
def mandelbrot(width, height, max_iter):
    result = np.zeros((height, width), dtype=np.uint8)
    for x in range(width):
        for y in range(height):
            zx, zy = 0.0, 0.0
            cx = (x - width / 2) * 4.0 / width
            cy = (y - height / 2) * 4.0 / width
            i = 0
            while zx * zx + zy * zy < 4 and i < max_iter:
                tmp = zx * zx - zy * zy + cx
                zy, zx = 2.0 * zx * zy + cy, tmp
                i += 1
            result[y, x] = i
    return result

WIDTH, HEIGHT = 800, 600
MAX_ITER = 100

start = time.time()
image = mandelbrot(WIDTH, HEIGHT, MAX_ITER)
print("Temps de calcul :", time.time() - start, "secondes")

plt.imshow(image, cmap='inferno')
plt.title("Fractale Mandelbrot accélérée avec Numba")
plt.axis('off')
plt.show()

TP : Corruption de python

En CPython (l'implémentation standard de Python), les objets int sont représentés par la structure C PyLongObject. Les petits entiers (entre -5 et 256) sont internés — c’est-à-dire partagés globalement pour des raisons de performance.

Ce TP manipule directement la mémoire de l’objet 1, pour altérer sa valeur de manière globale dans l’interpréteur.


  • Importation de ctypes : pour accéder bas-niveau à la mémoire.
  • Obtention de l’adresse mémoire de l’objet 1 via id(one).
  • Décalage mémoire de 24 octets pour accéder au champ ob_digit[0] (la "valeur" de l'entier, codée en base 2³⁰ sur les systèmes 64 bits).
  • Modification de la valeur de l'entier 1 en mémoire : digit_ptr[0] = 2.
  • Observation des effets de la corruption : par exemple, 1 + 1 ne vaut plus 2.
import ctypes

# ⚠️ WARNING: This will break Python
# All uses of 1 will now act weird

one = 1
print("Before corruption: 1 + 1 =", 1 + 1)
print("Before corruption: one + one =", one + one)

# Get memory address of `1`
addr = id(one)

# PyLongObject internals:
# [ ob_refcnt | ob_type | ob_size | ob_digit[0] ... ]
# On 64-bit systems, that's 24 bytes offset to the digits
digit_ptr = ctypes.cast(addr + 24, ctypes.POINTER(ctypes.c_uint32))

# Modify the digit
digit_ptr[0] = 2  # Now, `1` becomes 2 everywhere

a = 1
b = 1
print("Var after corruption a + b = ", a + b)
print("After corruption: 1 + 1 =", 1 + 1)
print("Before corruption: one + one =", one + one)
print("True == 1:", True == 1)

TP : Lire / Écrire un fichier binaire via memoryview

Comprendre comment manipuler un fichier binaire contenant des structures typées (int, float) sans recopier la mémoire. Cela est utile en data science, réseaux, ou systèmes embarqués pour accéder à des données binaires efficacement.

Étape 1 – Générer un fichier binaire contenant des paires (int, float)

On va utiliser le module struct pour transformer des données Python en bytes de manière structurée (comme en C). Ici, chaque structure contiendra un int suivi d’un float.

import struct

with open("data.bin", "wb") as f:
    for i in range(10):
        data = struct.pack("if", i, i * 1.5)  # 'i' = int32, 'f' = float32
        f.write(data)

Ce que fait ce code :

  • struct.pack("if", i, f) convertit deux valeurs en 8 octets binaires.
  • On écrit 10 blocs de 8 octets dans un fichier.

Étape 2 – Lire le fichier et créer un memoryview

Plutôt que de parser les bytes un à un ou de faire des copies, on crée une vue mémoire sur le contenu lu, ce qui permet une lecture ultra efficace sans recopier les données.

with open("data.bin", "rb") as f:
    content = f.read()  # contenu binaire brut

view = memoryview(content)

Ce que fait ce code :

  • f.read() lit tous les octets du fichier.
  • memoryview(content) permet d’accéder aux données en lecture seule sans les copier.

Étape 3 – Parcourir les structures via memoryview

On lit les données par blocs de 8 octets (4 pour l’int, 4 pour le float), puis on les décode avec struct.unpack().

for i in range(0, len(view), 8):
    int_part, float_part = struct.unpack("if", view[i:i+8])
    print(int_part, float_part)

Ce que fait ce code :

  • view[i:i+8] sélectionne une tranche de 8 octets.
  • struct.unpack("if", ...) décode ces octets en deux valeurs Python.

TP : Injecter une DLL/SO via ctypes.CDLL

Apprendre à utiliser ctypes pour charger dynamiquement une bibliothèque partagée (.so ou .dll) et appeler ses fonctions depuis Python.

Étape 1 – Créer une bibliothèque partagée en C

On crée une fonction C (triple) simple, puis on la compile pour qu'elle soit accessible à Python.

// libmath.c
int triple(int x) {
    return x * 3;
}
gcc -shared -o libmath.so -fPIC libmath.c  # Linux

La fonction triple prend un entier et renvoie sa valeur triplée. -fPIC génère du code position-independent, requis pour les bibliothèques partagées.

Étape 2 – Charger la bibliothèque avec ctypes.CDLL

import ctypes

lib = ctypes.CDLL("./libmath.so")

CDLL charge la bibliothèque dynamique pour pouvoir utiliser ses fonctions.

On charge le fichier compilé libmath.so et on le lie à l’objet lib.

Étape 3 – Appeler une fonction depuis Python

lib.triple.argtypes = [ctypes.c_int]
lib.triple.restype = ctypes.c_int

res = lib.triple(7)
print("Résultat :", res)  # 21
  • .argtypes définit les types d'arguments attendus.
  • .restype définit le type de retour.
  • On déclare que la fonction triple prend un int et retourne un int. Ensuite, on l’appelle avec l’argument 7.

TP : Microbench entre +, +=, sum(), np.sum(), numba

On compare les performances de différentes méthodes de somme (+, +=, sum(), np.sum(), numba) sur un grand tableau.

Étape 1 – Générer les données

import numpy as np

arr = np.arange(10_000_000, dtype=np.float64)

On prépare un grand vecteur pour simuler une charge réaliste de calcul en générant un tableau de 10 millions de nombres flottants consécutifs.

Étape 2 – Benchmark + (for classique)

import time

t0 = time.time()
s = 0.0
for x in arr:
    s = s + x
print("for + :", time.time() - t0)

Étape 3 – Benchmark +=

t0 = time.time()
s = 0.0
for x in arr:
    s += x
print("for += :", time.time() - t0)

Étape 4 – Benchmark sum()

t0 = time.time()
s = sum(arr)
print("sum() :", time.time() - t0)

Étape 5 – Benchmark np.sum

t0 = time.time()
s = np.sum(arr)
print("np.sum() :", time.time() - t0)

Étape 6 – Benchmark avec numba

from numba import njit

@njit
def fast_sum(arr):
    total = 0.0
    for x in arr:
        total += x
    return total

t0 = time.time()
s = fast_sum(arr)
print("numba sum :", time.time() - t0)

Explications

  • +, += sont lents car non vectorisés.
  • sum() est en pur Python.
  • np.sum() est optimisé C.
  • numba compile en code natif avec LLVM.

TP : Espionner une fonction avec sys.settrace

Utiliser sys.settrace pour suivre l’exécution ligne par ligne d’un script.

Étape 1 – Définir une fonction trace

import sys

def tracer(frame, event, arg):
    if event == "line":
        lineno = frame.f_lineno
        print(f"Exécution de la ligne {lineno}")
    return tracer

On utilise le système de hook interne pour capturer l'exécution des lignes.

Étape 2 – Définir un code simple à tracer

def foo():
    a = 1
    b = 2
    c = a + b
    print(c)

Étape 3 – Activer le tracer et appeler la fonction

sys.settrace(tracer)
foo()
sys.settrace(None)

settrace() active le hook, puis on exécute foo(), ligne par ligne.

TP : Générer du code Python à l’exécution (exec, compile)

Utiliser compile() pour générer dynamiquement du code et l’exécuter avec exec.

Étape 1 – Générer dynamiquement une fonction

code_str = """
def dynamic_func(x):
    return x * 2
"""
compiled = compile(code_str, "<string>", "exec")
exec(compiled)
print(dynamic_func(5))  # 10

On transforme une chaîne de texte en fonction Python exécutable.

Étape 2 – Compiler une expression

expr = compile("3 * 7 + 1", "<expr>", "eval")
print(eval(expr))  # 22

compile(..., "eval") est utilisé pour les expressions simples (contrairement à "exec").

TP : Manipulation avancée de threads et GIL

Comprendre le GIL (Global Interpreter Lock) et ses effets sur les threads.

Étape 1 – Code multithread simple

import threading

counter = 0
def f():
    global counter
    for i in range(10**6):
        a = counter
        counter = a + 1
        pass

threads = [threading.Thread(target=f)) for _ in range(4)]
[t.start() for t in threads]
[t.join() for t in threads]
print(counter)

On observe que les threads ne sont pas si parallèles à cause du GIL.

Étape 2 – Comparer avec multiprocessing

from multiprocessing import Process

counter = 0
processes = [Process(target=f) for _ in range(4)]
[p.start() for p in processes]
[p.join() for p in processes]
print(counter)

On contourne le GIL en créant des processus séparés.

Étape 3 – Utiliser threading.Lock

lock = threading.Lock()
counter = 0

def g():
    global counter
    for _ in range(100000):
        with lock:
            counter += 1

threads = [threading.Thread(target=g) for _ in range(4)]
[t.start() for t in threads]
[t.join() for t in threads]
print(counter)

Lock explicite avec threads

TP : Créer une base mémoire + gRPC

Créer un microservice gRPC qui stocke des données en mémoire (clé/valeur).

Étape 1 – Définir le .proto

syntax = "proto3";

service MemoryDB {
  rpc Put(Pair) returns (Empty);
  rpc Get(Key) returns (Value);
}

message Pair {
  string key = 1;
  string value = 2;
}

message Key {
  string key = 1;
}

message Value {
  string value = 1;
}

message Empty {}

On définit les messages et services gRPC. Cela sert d’interface réseau entre client et serveur.

Étape 2 – Générer le code gRPC

python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. memory.proto

Étape 3 – Implémenter le serveur

import grpc
from concurrent import futures
import memory_pb2
import memory_pb2_grpc

class Memory(memory_pb2_grpc.MemoryDBServicer):
    def __init__(self):
        self.store = {}

    def Put(self, request, context):
        self.store[request.key] = request.value
        return memory_pb2.Empty()

    def Get(self, request, context):
        value = self.store.get(request.key, "")
        return memory_pb2.Value(value=value)

server = grpc.server(futures.ThreadPoolExecutor())
memory_pb2_grpc.add_MemoryDBServicer_to_server(Memory(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()

Le serveur maintient un dictionnaire Python en RAM et expose les méthodes via gRPC.

Étape 4 – Client

import grpc
import memory_pb2
import memory_pb2_grpc

channel = grpc.insecure_channel("localhost:50051")
stub = memory_pb2_grpc.MemoryDBStub(channel)

stub.Put(memory_pb2.Pair(key="name", value="Alice"))
resp = stub.Get(memory_pb2.Key(key="name"))
print("Value:", resp.value)

TP Python : Manipuler des fichiers Excel avec openpyxl

Objectifs pédagogiques

  • Apprendre à lire, écrire et modifier un fichier Excel (.xlsx) en Python.
  • Savoir créer des feuilles, ajouter des données, formater des cellules.
  • Comprendre comment automatiser des traitements de données dans Excel.

Prérequis

Installer la bibliothèque openpyxl :

pip install openpyxl

Etape 1 : Création d’un fichier Excel

  1. Crée un fichier Excel rapport.xlsx.
  2. Crée une feuille appelée "Données".

Solution

from openpyxl import Workbook

wb = Workbook()
ws = wb.active
ws.title = "Données"

wb.save("rapport.xlsx")

Étape 2 : Remplir des données de ventes

  • Ajouter une ligne d’en-tête : "Mois" et "Ventes".

Ajouter les données de ventes suivantes :

MoisVentes
Janvier1200
Février1450
Mars1600
Avril1300
Mai1550
Juin1700

Solution

from openpyxl import load_workbook

# Charger le fichier
wb = load_workbook("rapport.xlsx")
ws = wb["Données"]

# Ajouter l’en-tête
ws.append(["Mois", "Ventes"])

# Ajouter les données
donnees = [
    ["Janvier", 1200],
    ["Février", 1450],
    ["Mars", 1600],
    ["Avril", 1300],
    ["Mai", 1550],
    ["Juin", 1700]
]

for ligne in donnees:
    ws.append(ligne)

wb.save("rapport.xlsx")

Etape 3: Ajouter un total automatique

  • Ajouter une ligne "Total" en dessous du tableau.
  • Utiliser une formule Excel pour calculer la somme de la colonne des ventes.

Solution

wb = load_workbook("rapport.xlsx")
ws = wb["Données"]

# Ajouter la ligne Total
ws["A8"] = "Total"
ws["B8"] = "=SUM(B2:B7)"

wb.save("rapport.xlsx")

Etape 4: Mise en forme

  • Mettre l’en-tête (ligne 1) en gras et fond gris clair.
  • Mettre la ligne de total (ligne 8) en gras.

Solution

from openpyxl.styles import Font, PatternFill

wb = load_workbook("rapport.xlsx")
ws = wb["Données"]

# Style de l’en-tête
header_font = Font(bold=True)
header_fill = PatternFill(start_color="DDDDDD", fill_type="solid")

for cell in ws[1]:  # Ligne 1
    cell.font = header_font
    cell.fill = header_fill

# Style de la ligne Total
for cell in ws[8]:  # Ligne 8
    cell.font = Font(bold=True)

wb.save("rapport.xlsx")

Etape 5 : Ajouter une colonne conditionnelle "Niveau"

  • Ajouter une colonne "Niveau" à droite des ventes.
  • Si la vente > 1300, écrire "Haute", sinon "Basse" via une formule Excel.

Solution

wb = load_workbook("rapport.xlsx")
ws = wb["Données"]

# Ajouter le titre de la colonne
ws["C1"] = "Niveau"

# Ajouter les formules conditionnelles
for i in range(2, 8):  # lignes 2 à 7
    ws[f"C{i}"] = f'=IF(B{i}>1300, "Haute", "Basse")'

wb.save("rapport.xlsx")

Etape 6 : Créer un graphique

  • Créer un graphique en barres affichant les ventes mensuelles.
  • Positionner le graphique à droite du tableau (ex. colonne E).

Solution

from openpyxl.chart import BarChart, Reference

wb = load_workbook("rapport.xlsx")
ws = wb["Données"]

# Créer le graphique
chart = BarChart()
chart.title = "Ventes mensuelles"

# Définir les données du graphique
data = Reference(ws, min_col=2, min_row=1, max_row=7)   # B1:B7
cats = Reference(ws, min_col=1, min_row=2, max_row=7)   # A2:A7

chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)

# Ajouter le graphique
ws.add_chart(chart, "E2")

wb.save("rapport.xlsx")

Etape 7 : Lire et filtrer les données Excel

  • Charger les données du fichier.
  • Afficher uniquement les mois dont les ventes > 1300€.

Solution

wb = load_workbook("rapport.xlsx", data_only=True)
ws = wb["Données"]

print("Mois avec ventes > 1300 € :")
for row in ws.iter_rows(min_row=2, max_row=7, min_col=1, max_col=2, values_only=True):
    mois, vente = row
    if vente > 1300:
        print(f"{mois} : {vente} €")

TP : Création de graphes en Python avec Matplotlib

Étape 1 : Tracer une courbe simple

🎯 Objectif pédagogique

Découvrir la création d’une figure, tracer une liste de valeurs, comprendre la base de Matplotlib.

📌 Consigne

  1. Crée une liste contenant les valeurs de 0 à 9.
  2. Calcule pour chaque valeur son carré.
  3. Trace la courbe y = x².
  4. Ajoute un titre au graphique.

Solution :

import matplotlib.pyplot as plt

# 1. Liste de valeurs
x = list(range(10))

# 2. Carré des valeurs
y = [i**2 for i in x]

# 3. Tracé
plt.plot(x, y)

# 4. Titre
plt.title("Courbe de y = x²")

# Affichage
plt.show()

Étape 2 : Ajouter des labels et une grille

🎯 Objectif pédagogique

Apprendre à annoter un graphique : axes, grille, personnalisation simple.

📌 Consigne

Reprends le graphique précédent et ajoute :

  • un label pour l’axe X,
  • un label pour l’axe Y,
  • une grille.

Solution

import matplotlib.pyplot as plt

x = list(range(10))
y = [i**2 for i in x]

plt.plot(x, y)
plt.title("Courbe de y = x²")

# Labels
plt.xlabel("Valeur de x")
plt.ylabel("Valeur de y")

# Grille
plt.grid(True)

plt.show()

Étape 3 : Tracer plusieurs courbes dans la même figure

🎯 Objectif pédagogique

Comprendre comment superposer plusieurs séries de données.

📌 Consigne

  1. Dans une même figure, trace :
    • y1 = x²
    • y2 = x³
  2. Ajoute une légende pour identifier chaque courbe.

Solution

import matplotlib.pyplot as plt

x = list(range(10))
y1 = [i**2 for i in x]
y2 = [i**3 for i in x]

plt.plot(x, y1, label="x²")
plt.plot(x, y2, label="x³")

plt.title("Comparaison entre x² et x³")
plt.xlabel("x")
plt.ylabel("Valeurs")

plt.grid(True)

# Ajout de la légende
plt.legend()

plt.show()

Étape 4 : Barres : créer un graphique à barres

🎯 Objectif pédagogique

Découvrir un autre type de graphique : histogramme / bar chart.

📌 Consigne

  1. Crée une liste de catégories : ["A", "B", "C", "D"].
  2. Associe une liste de valeurs au choix.
  3. Tracer un graphique en barres.

Solution

import matplotlib.pyplot as plt

categories = ["A", "B", "C", "D"]
valeurs = [5, 7, 3, 9]

plt.bar(categories, valeurs)

plt.title("Graphique à barres simple")
plt.xlabel("Catégories")
plt.ylabel("Valeurs")

plt.show()

Étape 5 : Sous-graphes (subplot)

🎯 Objectif pédagogique

Créer plusieurs graphiques dans une seule figure.

📌 Consigne

  1. Crée une figure contenant 2 graphiques côte à côte.
  2. À gauche : y = x²
  3. À droite : y = x³

Solution

import matplotlib.pyplot as plt

x = list(range(10))

# Création d'une figure avec 1 ligne, 2 colonnes
fig, axes = plt.subplots(1, 2, figsize=(10, 4))

# Graphique de gauche
axes[0].plot(x, [i**2 for i in x])
axes[0].set_title("y = x²")

# Graphique de droite
axes[1].plot(x, [i**3 for i in x])
axes[1].set_title("y = x³")

plt.show()

Étape 6 : Courbes stylisées

🎯 Objectif pédagogique

Apprendre à changer :

  • couleur,
  • style de ligne,
  • style des points.

📌 Consigne

Trace y = sqrt(x) avec :

  • une ligne pointillée,
  • des points cerclés,
  • en rouge,
  • un titre.
import matplotlib.pyplot as plt
import math

x = [i for i in range(1, 20)]
y = [math.sqrt(i) for i in x]

plt.plot(x, y, "ro--")  # r = red, o = cercle, -- = pointillé
plt.title("y = √x (courbe stylisée)")
plt.xlabel("x")
plt.ylabel("√x")

plt.grid(True)
plt.show()

Étape 7 – Importer des données et les tracer

🎯 Objectif pédagogique

Travailler avec de vraies données.

📌 Consigne

  1. Crée un fichier data.txt contenant une valeur par ligne, par exemple :
3
8
5
12
7
  1. Charge ces valeurs depuis le fichier.
  2. Trace un graphique en ligne.
import matplotlib.pyplot as plt

# 1. Lecture du fichier
with open("data.txt", "r") as f:
    valeurs = [int(ligne.strip()) for ligne in f]

# 2. Tracé
plt.plot(valeurs, marker="o")
plt.title("Données importées depuis data.txt")
plt.xlabel("Index")
plt.ylabel("Valeur")

plt.grid(True)
plt.show()

🐳 TP : Créer un Dockerfile pour une application Python

Ce TP vous guide pas à pas pour créer une application Python, l’encapsuler dans un conteneur Docker et l’optimiser.


Étape 1 – Créer une application Python simple

🎯 Objectif

Créer un script Python minimal qui servira de base.

📌 Consigne

Créer un fichier app.py affichant "Hello Docker".

✅ Solution

# app.py
print("Hello Docker")

Étape 2 – Ajouter un requirements.txt (optionnel)

🎯 Objectif

Gérer les dépendances Python dans l’image Docker.

📌 Consigne

  1. Créer requirements.txt.
  2. Ajouter requests.
  3. Modifier app.py pour l’importer.

✅ Solution

requirements.txt

requests==2.32.0

app.py

import requests
print("Hello Docker + Requests")

Étape 3 – Écrire un Dockerfile

🎯 Objectif

Créer un Dockerfile pour construire une image contenant l'application Python.

📌 Consigne

Créer un fichier Dockerfile contenant :

  • une image Python de base
  • les fichiers copiés
  • l'installation des dépendances
  • la commande de lancement

✅ Solution

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
COPY app.py .

RUN pip install --no-cache-dir -r requirements.txt

CMD ["python", "app.py"]

Étape 4 – Construire l’image Docker

🎯 Objectif

Construire une image locale basée sur le Dockerfile.

📌 Consigne

Créer une image nommée python-app:1.0.

✅ Solution

docker build -t python-app:1.0 .

Étape 5 – Exécuter le conteneur

🎯 Objectif

Vérifier que l’image fonctionne.

📌 Consigne

Lancer l’image créée.

✅ Solution

docker run --rm python-app:1.0

Étape 6 – Ajouter un serveur web Flask

🎯 Objectif

Apprendre à containeriser une vraie application web Python.

📌 Consigne

  1. Ajouter Flask dans requirements.txt
  2. Modifier app.py pour créer un mini serveur
  3. Exposer le port 5000
  4. Builder puis lancer l’image

✅ Solution

requirements.txt

flask==3.0.0

app.py

from flask import Flask

app = Flask(__name__)

@app.route("/")
def home():
    return "Hello from Flask inside Docker"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

Dockerfile

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]

Construire l’image

docker build -t flask-app:1.0 .

Exécuter le serveur

docker run -p 5000:5000 flask-app:1.0

Étape 7 – Optimiser l’image (bonus)

🎯 Objectif

Découvrir les bonnes pratiques pour réduire la taille de l'image.

📌 Consigne

Optimiser le Dockerfile en utilisant une image slim.

✅ Solution

FROM python:3.11-slim AS base

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

CMD ["python", "app.py"]

🧪 TP : Bonnes pratiques de conception de classes en Python

Ce TP vous guide pour apprendre à structurer des classes propres, lisibles, maintenables et conformes aux bonnes pratiques (PEP8, encapsulation, méthodes, héritage, dataclasses, etc.).


Étape 1 - Créer une classe simple bien structurée

🎯 Objectif

Comprendre la structure minimale d’une classe propre : attributs, constructeur, méthode.

📌 Consigne

Créer une classe Personne avec :

  • un attribut nom
  • un attribut age
  • une méthode se_presenter() qui affiche :
    "Je m'appelle X et j'ai Y ans"

Respecter la convention PEP8 :

  • noms en snake_case
  • classes en CamelCase

✅ Solution

class Personne:
    def __init__(self, nom: str, age: int):
        self.nom = nom
        self.age = age

    def se_presenter(self):
        print(f"Je m'appelle {self.nom} et j'ai {self.age} ans.")

Étape 2 - Encapsulation et propriétés (getter/setter)

🎯 Objectif

Apprendre à protéger les attributs internes grâce aux propriétés Python.

📌 Consigne

Dans la classe Personne :

  1. Rendre l’attribut age privé (self._age)
  2. Empêcher qu’un âge négatif soit défini
  3. Exposer un @property age + un setter

✅ Solution

class Personne:
    def __init__(self, nom: str, age: int):
        self.nom = nom
        self._age = None
        self.age = age  # passe par le setter

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, valeur):
        if valeur < 0:
            raise ValueError("L'âge ne peut pas être négatif.")
        self._age = valeur

Étape 3 - Méthodes de classes, statiques et d’instances

🎯 Objectif

Comprendre la différence entre :

  • méthode d’instance (self)
  • méthode de classe (cls)
  • méthode statique (pas d'argument automatique)

📌 Consigne

Dans Personne :

  1. Ajouter une méthode de classe depuis_chaine("Nom,Age")
  2. Ajouter une méthode statique est_majeur(age)
  3. Utiliser ces méthodes pour créer un objet et tester si la personne est majeure.

✅ Solution

class Personne:
    def __init__(self, nom: str, age: int):
        self.nom = nom
        self.age = age

    @classmethod
    def depuis_chaine(cls, chaine: str):
        nom, age = chaine.split(",")
        return cls(nom, int(age))

    @staticmethod
    def est_majeur(age: int):
        return age >= 18

Étape 4 - Bonnes pratiques avec l’héritage

🎯 Objectif

Découvrir un héritage simple, propre et maîtrisé.

📌 Consigne

Créer une classe Employe qui hérite de Personne, avec :

  • un attribut supplémentaire : salaire
  • une méthode afficher_salaire()

✅ Solution

class Employe(Personne):
    def __init__(self, nom: str, age: int, salaire: float):
        super().__init__(nom, age)
        self.salaire = salaire

    def afficher_salaire(self):
        print(f"Salaire : {self.salaire} €")

Étape 5 - Utiliser les dataclasses

🎯 Objectif

Apprendre une alternative propre et moderne pour définir des classes simples.

📌 Consigne

Réécrire la classe Personne en utilisant @dataclass.

⚠️ Rappel

Les dataclasses :

  • génèrent automatiquement __init__, __repr__, __eq__
  • sont idéales pour des objets “données”

✅ Solution

from dataclasses import dataclass

@dataclass
class Personne:
    nom: str
    age: int

Étape 6 - Ajouter des validations dans une dataclass

🎯 Objectif

Comprendre comment valider les données dans une dataclass via __post_init__.

📌 Consigne

Dans une dataclass :

  • vérifier que l’âge n’est pas négatif
  • lever une exception sinon

✅ Solution

from dataclasses import dataclass

@dataclass
class Personne:
    nom: str
    age: int

    def __post_init__(self):
        if self.age < 0:
            raise ValueError("L'âge ne peut pas être négatif.")

Étape 7 - Créer une classe propre avec logique métier

🎯 Objectif

Assembler toutes les bonnes pratiques en un seul exercice.

📌 Consigne

Créer une classe CompteBancaire propre, contenant :

  • attribut privé _solde
  • méthode deposer(montant)
  • méthode retirer(montant) qui empêche le solde négatif
  • propriété solde en lecture seule
  • méthode __repr__ lisible

✅ Solution

class CompteBancaire:
    def __init__(self, titulaire: str, solde_initial: float = 0.0):
        self.titulaire = titulaire
        self._solde = solde_initial

    @property
    def solde(self):
        return self._solde

    def deposer(self, montant: float):
        if montant <= 0:
            raise ValueError("Le montant doit être positif.")
        self._solde += montant

    def retirer(self, montant: float):
        if montant <= 0:
            raise ValueError("Le montant doit être positif.")
        if montant > self._solde:
            raise ValueError("Fonds insuffisants.")
        self._solde -= montant

    def __repr__(self):
        return f"CompteBancaire(titulaire='{self.titulaire}', solde={self._solde})"

🧪 TP : Visualisation et exploration de la mémoire Python

Ce TP vous permet de comprendre et explorer l’usage de la mémoire dans vos programmes Python à l’aide d’outils comme sys.getsizeof, pympler et tracemalloc.


Étape 1 - Mesurer la taille d’objets simples

🎯 Objectif

Comprendre combien de mémoire est utilisée par les objets Python de base.

📌 Consigne

  1. Importer le module sys
  2. Créer différents objets Python : int, float, str, list
  3. Afficher leur taille mémoire avec sys.getsizeof()

✅ Solution

import sys

a = 10
b = 3.14
c = "Bonjour"
d = [1, 2, 3, 4, 5]

print("Taille de a (int) :", sys.getsizeof(a), "octets")
print("Taille de b (float) :", sys.getsizeof(b), "octets")
print("Taille de c (str) :", sys.getsizeof(c), "octets")
print("Taille de d (list) :", sys.getsizeof(d), "octets")

Étape 2 - Explorer la mémoire d’une liste et de ses éléments

🎯 Objectif

Voir la différence entre la taille de la structure et des éléments qu’elle contient.

📌 Consigne

  1. Créer une liste de 1000 entiers
  2. Afficher la taille de la liste seule
  3. Calculer la taille totale incluant tous les éléments

✅ Solution

import sys

ma_liste = list(range(1000))
taille_liste = sys.getsizeof(ma_liste)
taille_elements = sum(sys.getsizeof(x) for x in ma_liste)

print("Taille de la liste :", taille_liste, "octets")
print("Taille totale des éléments :", taille_elements, "octets")
print("Taille totale approximative :", taille_liste + taille_elements, "octets")

Étape 3 - Visualiser la mémoire avec pympler

🎯 Objectif

Découvrir un outil spécialisé pour suivre la mémoire occupée par vos objets Python.

📌 Consigne

  1. Installer pympler si nécessaire :
pip install pympler
  1. Utiliser asizeof pour mesurer la mémoire totale utilisée par un objet complexe.

✅ Solution

from pympler import asizeof

ma_liste = [list(range(100)) for _ in range(1000)]

print("Mémoire totale de ma_liste :", asizeof.asizeof(ma_liste), "octets")

Étape 4 - Suivre l’allocation mémoire avec tracemalloc

🎯 Objectif

Suivre les allocations mémoire dans votre programme en temps réel.

📌 Consigne

  1. Importer tracemalloc
  2. Démarrer le suivi
  3. Créer des objets volumineux
  4. Afficher les 5 plus gros emplacements de mémoire

✅ Solution

import tracemalloc

tracemalloc.start()

# Création de données volumineuses
data = [list(range(1000)) for _ in range(1000)]

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

print("Top 5 lignes consommant le plus de mémoire :")
for stat in top_stats[:5]:
    print(stat)

Étape 5 - Comparer l’usage mémoire avant et après suppression d’objets

🎯 Objectif

Observer l’effet du garbage collector sur la mémoire.

📌 Consigne

  1. Créer un objet volumineux
  2. Mesurer la mémoire utilisée
  3. Supprimer l’objet avec del
  4. Forcer le garbage collector et mesurer à nouveau

✅ Solution

import gc
import tracemalloc

tracemalloc.start()

big_list = [list(range(1000)) for _ in range(1000)]
print("Mémoire après création :", tracemalloc.get_traced_memory()[1], "octets")

del big_list
gc.collect()

print("Mémoire après suppression et GC :", tracemalloc.get_traced_memory()[1], "octets")

Étape 6 - Bonus : Créer une fonction pour visualiser l’usage mémoire d’un objet

🎯 Objectif

Créer un outil réutilisable pour explorer la mémoire de n’importe quel objet.

📌 Consigne

Créer une fonction explorer_memoire(obj) qui affiche :

  • taille totale avec asizeof
  • taille de l’objet avec sys.getsizeof

✅ Solution

import sys
from pympler import asizeof

def explorer_memoire(obj):
    taille_simple = sys.getsizeof(obj)
    taille_totale = asizeof.asizeof(obj)
    print(f"Taille simple : {taille_simple} octets")
    print(f"Taille totale : {taille_totale} octets")

ma_liste = [list(range(100)) for _ in range(1000)]
explorer_memoire(ma_liste)

TP : Modifier, Filtrer et Valider un CSV avec des Décorateurs en Python

Partie 1 : Lecture d'un fichier CSV

Objectif

Apprendre à lire un fichier CSV ligne par ligne en Python.

Consigne

Créez une fonction lire_csv qui lit un fichier CSV et renvoie chaque ligne sous forme de dictionnaire.

Solution

import csv

def lire_csv(fichier):
    with open(fichier, newline='') as csvfile:
        reader = csv.DictReader(csvfile)
        for ligne in reader:
            yield ligne

# Exemple d'utilisation
for ligne in lire_csv('exemple.csv'):
    print(ligne)

Partie 2 : Créer un décorateur pour modifier les lignes

Objectif

Appliquer une transformation à chaque ligne du CSV via un décorateur.

Consigne

Créez un décorateur modifier_ligne qui prend une fonction de transformation et l'applique à chaque ligne.

Solution

def modifier_ligne(func):
    def wrapper(ligne):
        ligne['nom'] = ligne['nom'].upper()
        return func(ligne)
    return wrapper

@modifier_ligne
def majuscule_nom(ligne):
    return ligne

# Exemple
for ligne in lire_csv('exemple.csv'):
    print(majuscule_nom(ligne))

Partie 3 : Créer un décorateur pour filtrer les lignes

Objectif

Filtrer certaines lignes selon un critère avec un décorateur.

Consigne

Créez un décorateur filtrer_ligne qui garde uniquement les lignes répondant à une condition.

Solution

def filtrer_ligne(func):
    def wrapper(ligne):
        if int(ligne['age']) >= 18:
            return func(ligne)
        return None
    return wrapper

@filtrer_ligne
def afficher_adultes(ligne):
    return ligne

# Exemple
for ligne in lire_csv('exemple.csv'):
    resultat = afficher_adultes(ligne)
    if resultat:
        print(resultat)

Partie 4 : Créer un décorateur pour valider les lignes

Objectif

Valider que chaque ligne respecte certaines règles.

Consigne

Créez un décorateur valider_ligne qui lève une exception si une règle n’est pas respectée.

Solution

def valider_ligne(func):
    def wrapper(ligne):
        if 'nom' not in ligne or not ligne['nom']:
            raise ValueError('La ligne doit contenir un nom')
        return func(ligne)
    return wrapper

@valider_ligne
def afficher_ligne_validee(ligne):
    return ligne

# Exemple
for ligne in lire_csv('exemple.csv'):
    try:
        print(afficher_ligne_validee(ligne))
    except ValueError as e:
        print('Erreur:', e)

Partie 5 : Combiner modification, filtrage et validation

Objectif

Appliquer successivement modification, filtrage et validation sur chaque ligne.

Consigne

Combinez les trois décorateurs pour traiter le CSV en une seule fonction.

Solution

@valider_ligne
@filtrer_ligne
@modifier_ligne
def traiter_ligne(ligne):
    ligne['nom'] = ligne['nom'].title()
    return ligne

# Exemple
for ligne in lire_csv('exemple.csv'):
    resultat = traiter_ligne(ligne)
    if resultat:
        print(resultat)

TP : Créer un .exe à partir d'un script Python

Partie 1 : Installer PyInstaller

Objectif

Installer l'outil nécessaire pour convertir un script Python en exécutable Windows.

Consigne

Installez PyInstaller via pip.

Solution

pip install pyinstaller

Partie 2 : Créer un script Python simple

Objectif

Préparer un script Python à convertir en .exe.

Consigne

Créez un fichier bonjour.py qui affiche un message.

Solution

# bonjour.py
print("Bonjour, ceci est un script Python converti en exe !")
input("Appuyez sur Entrée pour fermer...")

Partie 3 : Générer un .exe simple

Objectif

Utiliser PyInstaller pour créer un exécutable simple.

Consigne

Exécutez PyInstaller pour générer le fichier .exe.

Solution

pyinstaller --onefile bonjour.py

Partie 4 : Localiser le fichier .exe

Objectif

Trouver et tester l'exécutable généré.

Consigne

Vérifiez dans le dossier dist le fichier bonjour.exe et exécutez-le.

Solution

# Le fichier se trouve ici
dist/bonjour.exe

# Double-cliquez dessus ou exécutez dans le terminal
dist\bonjour.exe

Partie 5 : Ajouter une icône à l'exécutable

Objectif

Personnaliser le .exe avec une icône.

Consigne

Créez une icône .ico et utilisez-la avec PyInstaller.

Solution

pyinstaller --onefile --icon=icone.ico bonjour.py

Partie 6 : Créer un .exe sans console

Objectif

Supprimer la console pour les applications GUI.

Consigne

Utilisez l’option --windowed pour générer un .exe sans console.

Solution

pyinstaller --onefile --windowed --icon=icone.ico bonjour.py

TP : Projet complet : Architecture Python, classes avancées, décorateurs, closures, traitement CSV, serveur web et persistance (MySQL + Docker optionnel)


Partie 1 : Conception et architecture

Objectifs pédagogiques

  • Structurer le code en modules clairs.
  • Montrer usage de classes, métaclasse, classes abstraites.
  • Séparer responsabilité : configuration, modèles, stockage, pipeline, serveur.

Consignes

  1. Crée un dossier project/ contenant une arborescence claire inspirée de celle proposée dans le sujet.
  2. Organise ton application Python en plusieurs modules :
    • config.py pour gérer la configuration.
    • models.py pour les classes métier.
    • pipeline.py pour la logique de transformation/validation.
    • storage.py pour la persistance des données.
    • server.py pour exposer une API web.
    • utils.py pour les fonctions génériques.
  3. Crée un fichier requirements.txt listant toutes les dépendances nécessaires.
  4. Ajoute un dossier data/ pour stocker les données brutes (ex : CSV).
  5. Assure-toi que chaque fichier module contient un rôle clair et unique.

Solution

project/
├── app/
│   ├── __init__.py
│   ├── config.py
│   ├── models.py
│   ├── storage.py
│   ├── pipeline.py
│   ├── server.py
│   └── utils.py
├── data/
│   └── people.csv
├── migrations/            # (optionnel) migrations SQL
├── Dockerfile             # (optionnel)
├── docker-compose.yml     # (optionnel pour MySQL)
├── requirements.txt
└── README.md

Partie 2 : Fichiers essentiels (exemples)

Consignes

  1. Crée un fichier requirements.txt avec les dépendances nécessaires au projet.
  2. Ajoute un fichier CSV dans data/people.csv contenant des exemples de données volontairement invalides.

requirements.txt

flask==2.2.5
pandas==2.1.0
sqlalchemy==2.0.19
pymysql==1.0.3
python-dotenv==1.0.0

Vérifie que ton projet peut être installé via :

pip install -r requirements.txt

Exemple de data/people.csv (jeu de données)

id,name,age,email,role
1,Alice,30,alice@example.com,admin
2,Bob,notanumber,bob@example.com,user
3,,22,anon@example.com,user
4,Charlie,17,charlie@example.com,guest
5,David,45,david@example.com,user
6,Eve,130,eve@example.com,user

Partie 3 : Configuration

Consignes

  1. Crée le fichier app/config.py.
  2. Mets en place un chargement automatique de variables d’environnement via dotenv.
  3. Ajoute des variables configurables :
    • Mode (ENV)
    • Debug (DEBUG)
    • Connexion MySQL (host, port, user, password, database)
  4. Expose une variable unique : DATABASE_URI.
  5. Teste ton fichier en modifiant tes variables d’environnement.

app/config.py : lecture de la config via env et fichier .env

# app/config.py
import os
from dotenv import load_dotenv

load_dotenv()  # charge .env si présent

class Config:
    ENV = os.getenv("ENV", "development")
    DEBUG = os.getenv("DEBUG", "1") == "1"
    MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
    MYSQL_PORT = int(os.getenv("MYSQL_PORT", 3306))
    MYSQL_USER = os.getenv("MYSQL_USER", "root")
    MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "password")
    MYSQL_DB = os.getenv("MYSQL_DB", "example_db")
    DATABASE_URI = (
        f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}"
    )

Partie 4 : Modèles, métaclasse et classe abstraite

Consignes

  1. Dans app/models.py, crée :
    • Une métaclasse ModelRegistry qui enregistre les classes créées.
    • Une classe abstraite BaseModel imposant :
      • to_dict()
      • validate()
  2. Crée une classe Person héritant de BaseModel :
    • Ajoute des attributs (id, name, age, email, role).
    • Implémente une méthode validate() qui vérifie :
      • id et age sont des ints valides.
      • Age est compris entre 0 et 120
      • name n’est pas vide.
      • email contient @.
  3. Vérifie que ta classe apparaît bien dans ModelRegistry.registry.

Contexte

  • On crée une BaseModel abstraite qui impose to_dict() et validate().
    • to_dict permet d'avoir un objet safe a retourner en sortie publique (API etc...)
  • On crée une métaclasse qui enregistre les classes modèles pour introspection.
    • Cela permet de sauvegarder les modèles de données

app/models.py

# app/models.py
from abc import ABC, abstractmethod, ABCMeta
from typing import Dict

# Metaclass simple pour enregistrer modèles
class ModelRegistry(ABCMeta):
    registry = {}

    def __new__(mcls, name, bases, namespace):
        cls = super().__new__(mcls, name, bases, namespace)
        if name != "BaseModel":
            ModelRegistry.registry[name] = cls
        return cls

class BaseModel(ABC, metaclass=ModelRegistry):
    """Interface pour tous les modèles du domaine."""

    @abstractmethod
    def to_dict(self) -> dict:
        raise NotImplementedError

    @abstractmethod
    def validate(self) -> bool:
        raise NotImplementedError

# Exemple de modèle concret
class Person(BaseModel):
    def __init__(self, id: int, name: str, age: int, email: str, role: str):
        self.id = id
        self.name = name
        self.age = age
        self.email = email
        self.role = role

    def to_dict(self):
        return {
            "id": self.id,
            "name": self.name,
            "age": self.age,
            "email": self.email,
            "role": self.role,
        }

    def validate(self) -> bool:
        """Validation basique : id int, name non vide, 0<=age<=120, email contient @"""
        if not isinstance(self.id, int):
            return False
        if not self.name:
            return False
        if not isinstance(self.age, int) or not (0 <= self.age <= 120):
            return False
        if "@" not in self.email:
            return False
        return True

Partie 5 : Décorateurs & closures,pipeline de transformation/validation

Consignes

  1. Crée dans app/pipeline.py plusieurs décorateurs paramétrables :
    • cast_field(field, caster) : transforme le type d’un champ.
    • normalize_field(field, func_norm) : nettoie un champ string.
    • require_field(field) : ignore la ligne si le champ est vide.
    • filter_condition(predicate) : ne garde la ligne que si la condition est vraie.
  2. Implémente ces décorateurs via des closures.
  3. Crée une fonction finale process_row_to_person(row) :
    • Empile plusieurs décorateurs.
    • Construit un objet Person si tous les tests passent.
    • Retourne None si une étape échoue.
  4. Teste ton pipeline avec un jeu de données simple.

Objectif

Construire un pipeline où chaque étape est un décorateur (modification, filtrage, validation). Utiliser closures pour paramétrer les décorateurs.

app/pipeline.py

# app/pipeline.py
from functools import wraps
from typing import Callable, Optional

# décorateur pour caster un champ
def cast_field(field: str, caster: Callable):
    def decorator(func):
        @wraps(func)
        def wrapper(row: dict):
            try:
                row[field] = caster(row[field])
            except Exception:
                row[field] = None
            return func(row)
        return wrapper
    return decorator

# décorateur pour normaliser une chaine (closure qui renvoie le décorateur)
def normalize_field(field: str, func_norm: Callable[[str], str]):
    def decorator(fn):
        @wraps(fn)
        def wrapper(row: dict):
            v = row.get(field)
            if isinstance(v, str):
                row[field] = func_norm(v)
            return fn(row)
        return wrapper
    return decorator

# décorateur pour valider un champ obligatoire
def require_field(field: str):
    def decorator(fn):
        @wraps(fn)
        def wrapper(row: dict):
            if row.get(field) in ("", None):
                return None
            return fn(row)
        return wrapper
    return decorator

# décorateur filtre général
def filter_condition(predicate: Callable[[Dict], bool]):
    def decorator(fn):
        @wraps(fn)
        def wrapper(row: dict):
            try:
                if not predicate(row):
                    return None
            except Exception:
                return None
            return fn(row)
        return wrapper
    return decorator

# pipeline "final" qui construit un Person si tout ok
from .models import Person

@cast_field("id", int)
@cast_field("age", int)
@normalize_field("name", lambda s: s.strip().title())
@require_field("name")
@filter_condition(lambda r: r.get("age") is not None and r["age"] >= 18)
def process_row_to_person(row: dict) -> Optional[Person]:
    # row now has typed fields; build model
    p = Person(
        id=row["id"],
        name=row["name"],
        age=row["age"],
        email=row.get("email", ""),
        role=row.get("role", "user"),
    )
    if not p.validate():
        return None
    return p

Partie 6 : Utils: lecture CSV et fichier de config

Consignes

  1. Dans app/utils.py, crée :
    • Une fonction read_csv(path) qui renvoie une liste de dicts.
    • Une fonction write_csv(path, rows, fieldnames) qui enregistre une liste de dicts.
  2. Charge le fichier CSV data/people.csv.
  3. Passe chaque ligne dans process_row_to_person.
  4. Vérifie que certaines lignes sont invalides et correctement filtrées.

app/utils.py

# app/utils.py
import csv
from typing import Callable, Optional
from pathlib import Path

def read_csv(path: str) -> list[dict]:
    with open(path, newline='', encoding='utf-8') as f:
        reader = csv.DictReader(f)
        return [dict(row) for row in reader]

def write_csv(path: str, rows: list[dict], fieldnames):
    Path(path).parent.mkdir(parents=True, exist_ok=True)
    with open(path, 'w', newline='', encoding='utf-8') as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for r in rows:
            writer.writerow(r)

Partie 7 : Stockage (SQLAlchemy) app/storage.py

Consignes

  1. Dans app/storage.py, crée une classe Storage :
    • Initialise un moteur SQLAlchemy à partir de Config.DATABASE_URI.
    • Crée une table SQL person.
  2. Crée un DTO (objet de transfert de données) PersonDTO mappé à la table via le registry SQLAlchemy.
  3. Ajoute une méthode save_person(person) pour faire un upsert simple.
  4. Ajoute une méthode optionnelle fetch_all_persons().
  5. Teste :
    • Insertion d’un enregistrement valide.
    • Récupération des données depuis la base.

Objectif

Sauvegarder les Person dans MySQL via SQLAlchemy, en utilisant l'URI de config.Config.

app/storage.py

# app/storage.py
from sqlalchemy import create_engine, Column, Integer, String, MetaData, Table
from sqlalchemy.orm import registry, Session
from .config import Config

mapper_registry = registry()
metadata = MetaData()

person_table = Table(
    "person",
    metadata,
    Column("id", Integer, primary_key=True),
    Column("name", String(255)),
    Column("age", Integer),
    Column("email", String(255)),
    Column("role", String(50)),
)

# simple mapper object
class PersonDTO:
    def __init__(self, id, name, age, email, role):
        self.id = id
        self.name = name
        self.age = age
        self.email = email
        self.role = role

mapper_registry.map_imperatively(PersonDTO, person_table)

class Storage:
    def __init__(self, uri: str = None):
        uri = uri or Config.DATABASE_URI
        self.engine = create_engine(uri, echo=False, future=True)
        metadata.create_all(self.engine)

    def save_person(self, person):
        with Session(self.engine) as session:
            dto = PersonDTO(person.id, person.name, person.age, person.email, person.role)
            session.merge(dto)  # merge pour upsert basique
            session.commit()

Partie 8 : Serveur web (Flask) app/server.py

Consignes

  1. Dans app/server.py, crée une application Flask avec create_app.
  2. Ajoute une route POST /import :
    • Reçoit un JSON contenant { "path": "<fichier.csv>" }.
    • Lit le CSV.
    • Passe chaque ligne dans le pipeline.
    • Sauvegarde les Person valides.
    • Retourne la liste des personnes importées.
  3. Ajoute une route GET /persons :
    • Retourne le contenu de la table SQL.
  4. Vérifie le comportement avec des données invalides.

Démarre ton serveur et teste avec :

curl -X POST -H "Content-Type: application/json" \
-d '{"path": "data/people.csv"}' \
http://localhost:5000/import

Objectif

Exposer une API minimaliste :

  • POST /import : lit CSV et importe valid rows
  • GET /persons : retourne personnes importées (depuis DB ou mémoire selon implémentation)

app/server.py

# app/server.py
from flask import Flask, request, jsonify
from .utils import read_csv
from .pipeline import process_row_to_person
from .storage import Storage
from sqlalchemy import text

def create_app(storage: Storage = None):
    app = Flask(__name__)
    storage = storage or Storage()

    @app.route("/import", methods=["POST"])
    def import_csv():
        data_path = request.json.get("path")
        if not data_path:
            return jsonify({"error": "path required"}), 400
        rows = read_csv(data_path)
        imported = []
        for row in rows:
            person = process_row_to_person(row)
            if person is not None:
                storage.save_person(person)
                imported.append(person.to_dict())
        return jsonify({"imported": imported}), 200

    @app.route("/persons", methods=["GET"])
    def list_persons():
        # Pour simplicité, faites une requête directe SQL (ou ajout d'un method fetch_all)
        with storage.engine.connect() as conn:
            query = "SELECT id,name,age,email,role FROM person";
            result = conn.execute(text(query))
            persons = [dict(row._mapping) for row in result]
        return jsonify(persons)

    return app

# pour lancer localement
if __name__ == "__main__":
    app = create_app()
    app.run(host="0.0.0.0", port=5000, debug=True)

Partie 9 : Docker & MySQL (optionnel)

Consignes

  1. Crée un Dockerfile pour packager ton application.
  2. Crée un docker-compose.yml contenant :
    • Un service MySQL.
    • Un service web qui dépend de MySQL.
  3. Configure les variables d’environnement dans docker-compose.yml.
  4. Teste ensuite les routes via l’API avec les deux services en fonctionnement.

Lance :

docker compose up --build

Dockerfile pour l'app

# Dockerfile
FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000
CMD ["python", "-m", "app.server"]

docker-compose.yml (optionnel)

version: "3.8"
services:
  db:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: example_db
    ports:
      - "3306:3306"
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  web:
    build: .
    depends_on:
      db:
        condition: service_healthy
    environment:
      MYSQL_HOST: db
      MYSQL_USER: root
      MYSQL_PASSWORD: password
      MYSQL_DB: example_db
    ports:
      - "5000:5000"

Partie 10 : Tests rapides & exécution

Consignes

  1. Crée un dossier tests/.
  2. Ajoute un test pour :
    • process_row_to_person() (cas valides et invalides).
    • Storage.save_person().
  3. Ajoute un test d’intégration sur /import.
  4. (Optionnel) Configure GitHub Actions pour automatiser les tests.

Lancer sans Docker

  1. Installer dépendances :
python -m pip install -r requirements.txt
  1. Créer une base MySQL locale ou modifier Config.DATABASE_URI pour utiliser SQLite temporairement :
# dans app/config.py pour dev rapide
DATABASE_URI = "sqlite:///dev.db"
  1. Lancer le serveur :
python -m app.server
  1. Importer via curl :
curl -X POST -H "Content-Type: application/json" -d '{"path":"data/people.csv"}' http://localhost:5000/import
  1. Consulter personnes :
curl http://localhost:5000/persons

Lancer avec Docker Compose (optionnel)

docker compose up --build
# puis la même requête POST vers le service web http://localhost:5000/import

Partie 11 : Exercice guidé (à faire)

Étapes demandées

  1. Séparer les responsabilités : déplacer la logique SQL dans storage.py (méthode fetch_all_persons) et ne pas exécuter SQL brut dans server.py.
    1. Ajouter from sqlalchemy import text
  2. Logger : ajouter logging structuré partout (logging).
  3. Tests unitaires : écrire pytest pour pipeline.process_row_to_person avec cas valides/invalides.
  4. Sécurité : échappez/validez les champs (déjà partiellement fait).
  5. Pagination : implémenter pagination pour /persons.
  6. Métriques : exposer le nombre de lignes importées et le nombre rejeté.

Partie 12 : Explications

Métaclasse

  • ModelRegistry permet d'introspecter toutes les classes modèles créées (utile pour migrations, sérialisation automatique).

Classe abstraite

  • BaseModel force l'implémentation des méthodes essentielles (to_dict, validate) pour homogénéité.

Décorateurs & closures

  • Les décorateurs cast_field, normalize_field, filter_condition sont paramétrables grâce aux closures et composables (empilement @).

Architecture

  • config.py = configuration
  • models.py = règles métier (validation)
  • pipeline.py = transformation/validation (stateless)
  • storage.py = accès à la base
  • server.py = interface HTTP

Solutions / snippets utiles supplémentaires

storage.fetch_all_persons() (amélioration)

# dans app/storage.py, ajouter :
from sqlalchemy import select

class Storage:
    # ... init ...
    def fetch_all_persons(self, limit=100, offset=0):
        stmt = select(person_table).limit(limit).offset(offset)
        with self.engine.connect() as conn:
            result = conn.execute(stmt)
            return [dict(r._mapping) for r in result]

Exemple de test pour pipeline (pytest)

# tests/test_pipeline.py
from app.pipeline import process_row_to_person

def test_valid_row():
    row = {"id": "10", "name": "  john doe ", "age": "25", "email": "j@example.com", "role": "user"}
    p = process_row_to_person(row)
    assert p is not None
    assert p.name == "John Doe"
    assert p.age == 25

def test_invalid_age():
    row = {"id":"11","name":"Ann","age":"notanint","email":"a@b.com","role":"user"}
    p = process_row_to_person(row)
    assert p is None

Conseils & bonnes pratiques

  • Séparez logique métier et persistence (Repository pattern).
  • Validez tôt : cast/validate en entrée.
  • Utilisez env vars pour secrets (ne pas committer .env).
  • Faites des migrations (Alembic) pour la base SQL si projet évolue.
  • Ajoutez des métriques et logs pour production.
  • Tester votre pipeline avec divers cas (limites, valeurs manquantes, injections).

TP : Gestion d’un Tableau + PDF contenant un Tableau + Upload & Validation

🎯 Objectif général du TP

Vous allez développer une mini-application Python capable de :

  • Générer un PDF contenant un tableau
  • Uploader ce PDF vers un serveur FastAPI
  • Vérifier que le PDF contient bien un tableau (validation)
  • Stocker les informations dans une base JSON
  • Afficher un tableau HTML listant les PDF uploadés
  • Télécharger les PDF depuis un lien

Ce TP couvre :

  • Manipulation de PDF
  • Analyse de PDF (détection de tableau)
  • FastAPI
  • HTML (templates Jinja2)
  • CRUD simplifié
  • Validation & upload de fichiers

🧱 Partie 1 : Préparation du projet

📘 Consignes

  1. Créer un dossier tp_pdf_table.
  2. Créer les sous-dossiers :
mkdir -p tp_pdf_table/templates
mkdir -p tp_pdf_table/static/pdf
  1. Installer les dépendances :
pip install fastapi uvicorn jinja2 reportlab pdfplumber python-multipart
  1. Ajouter un fichier JSON vide :
echo [] > tp_pdf_table/database.json
  1. Vérifier que votre structure ressemble à ceci :
tp_pdf_table/
│
├── main.py
├── generate_pdf.py
├── templates/
│   ├── index.html
│   └── upload.html
├── static/
│   └── pdf/
└── database.json

Objectif de la partie : mettre en place l’environnement de base.


🏗 Partie 2 : Générer un PDF contenant un tableau

📘 Consignes

  1. Créer un fichier generate_pdf.py.
  2. Lire le code fourni ci-dessous et comprendre comment ReportLab construit un tableau.
  3. Exécuter le fichier pour générer automatiquement un PDF example_table.pdf.
  4. Ouvrir le PDF pour vérifier que le tableau apparaît correctement.

🧩 Code à mettre dans generate_pdf.py

from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle
from reportlab.lib import colors

def generate_pdf_with_table(output_path):
    data = [
        ["ID", "Nom", "Valeur"],
        ["1", "Produit A", "23"],
        ["2", "Produit B", "47"],
        ["3", "Produit C", "12"]
    ]

    pdf = SimpleDocTemplate(output_path, pagesize=A4)
    table = Table(data)

    style = TableStyle([
        ('BACKGROUND', (0,0), (-1,0), colors.grey),
        ('TEXTCOLOR', (0,0), (-1,0), colors.white),
        ('GRID', (0,0), (-1,-1), 1, colors.black),
        ('ALIGN', (0,0), (-1,-1), 'CENTER')
    ])

    table.setStyle(style)

    pdf.build([table])


if __name__ == "__main__":
    generate_pdf_with_table("example_table.pdf")

🔍 Partie 3 : Analyse PDF : vérifier la présence d’un tableau

📘 Consignes

  1. Installer pdfplumber si ce n’est pas déjà fait.
  2. Lire un PDF et détecter s’il contient un tableau.
  3. Implémenter une fonction pdf_contains_table() dans main.py.
  4. Comprendre le principe : "pas de tableau → upload refusé".

🧩 Code à ajouter dans main.py

import pdfplumber

def pdf_contains_table(file_path: str) -> bool:
    with pdfplumber.open(file_path) as pdf:
        page = pdf.pages[0]
        tables = page.extract_tables()
        return len(tables) > 0 and len(tables[0]) >= 2 and len(tables[0][0]) >= 2

🚀 Partie 4 : Développer l’API FastAPI

📘 Consignes

  1. Créer un fichier main.py.
  2. Copier le code complet fourni ci-dessous.
  3. Lire attentivement :
    • comment on charge la base JSON
    • comment se déroule l’upload du PDF
    • comment la validation est faite
  4. Lancer l’API pour tester les premières routes.

🧩 Code complet main.py

from fastapi import FastAPI, File, UploadFile, Form
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi import Request
import json, uuid, os
import pdfplumber

app = FastAPI()
templates = Jinja2Templates(directory="templates")

DB_FILE = "database.json"
PDF_DIR = "static/pdf"

def load_db():
    with open(DB_FILE, "r") as f:
        return json.load(f)

def save_db(data):
    with open(DB_FILE, "w") as f:
        json.dump(data, f, indent=4)

def pdf_contains_table(file_path: str) -> bool:
    with pdfplumber.open(file_path) as pdf:
        page = pdf.pages[0]
        tables = page.extract_tables()
        return len(tables) > 0 and len(tables[0]) >= 2 and len(tables[0][0]) >= 2

@app.get("/", response_class=HTMLResponse)
def index(request: Request):
    data = load_db()
    return templates.TemplateResponse("index.html", {"request": request, "docs": data})

@app.get("/upload", response_class=HTMLResponse)
def upload_form(request: Request):
    return templates.TemplateResponse("upload.html", {"request": request})

@app.post("/upload")
async def upload_pdf(request: Request, title: str = Form(...), file: UploadFile = File(...)):
    if file.content_type != "application/pdf":
        return {"error": "Seuls les PDF sont autorisés"}

    file_id = str(uuid.uuid4())
    file_path = os.path.join(PDF_DIR, f"{file_id}.pdf")

    with open(file_path, "wb") as f:
        f.write(await file.read())

    if not pdf_contains_table(file_path):
        os.remove(file_path)
        return {"error": "Le PDF ne contient pas de tableau."}

    data = load_db()
    data.append({
        "id": file_id,
        "title": title,
        "filename": f"{file_id}.pdf"
    })
    save_db(data)

    return {"message": "PDF uploadé et validé avec succès!"}

@app.get("/pdf/{file_id}")
def get_pdf(file_id: str):
    file_path = os.path.join(PDF_DIR, f"{file_id}.pdf")
    if not os.path.exists(file_path):
        return {"error": "PDF introuvable"}
    return FileResponse(file_path, media_type="application/pdf")

🎨 Partie 5 : Créer les pages HTML

📘 Consignes

  1. Créer deux pages HTML dans templates/.
  2. index.html doit afficher :
    • un tableau des PDF validés
    • un lien pour uploader un PDF
  3. upload.html doit afficher :
    • un formulaire
    • un champ pour le titre
    • un champ de fichier
  4. Bien vérifier les balises Jinja2.

🧩 templates/index.html

<!DOCTYPE html>
<html>
<head>
    <title>Tableau de PDF</title>
</head>
<body>

<h1>Liste des PDF contenant un tableau</h1>

<a href="/upload">Uploader un PDF</a>

<table border="1" cellspacing="0" cellpadding="10" style="margin-top:20px;">
    <tr>
        <th>ID</th>
        <th>Titre</th>
        <th>Lien</th>
    </tr>

    {% for doc in docs %}
    <tr>
        <td>{{ doc.id }}</td>
        <td>{{ doc.title }}</td>
        <td><a href="/pdf/{{ doc.id }}" target="_blank">Ouvrir le PDF</a></td>
    </tr>
    {% endfor %}
</table>

</body>
</html>

🧩 templates/upload.html

<!DOCTYPE html>
<html>
<head>
    <title>Upload PDF</title>
</head>
<body>

<h1>Uploader un PDF contenant un tableau</h1>

<form action="/upload" method="post" enctype="multipart/form-data">
    <label>Titre :</label><br>
    <input type="text" name="title" required><br><br>

    <label>Fichier PDF :</label><br>
    <input type="file" name="file" accept="application/pdf" required><br><br>

    <button type="submit">Envoyer</button>
</form>

<a href="/">Retour</a>

</body>
</html>

🚀 Partie 6 : Lancer et tester le serveur

📘 Consignes

  1. Lancer le serveur :
uvicorn main:app --reload
  1. Aller sur :
    http://127.0.0.1:8000/
  2. Uploader un fichier PDF :
    • avec un tableau → accepté
    • sans tableau → rejeté
  3. Vérifier que le tableau HTML se met à jour.
  4. Tester le téléchargement.

🎉 Résultat final

L'application permet maintenant de :

✔ Générer un PDF contenant un tableau
✔ Uploader un PDF
✔ Valider automatiquement que le PDF contient un tableau
✔ Stocker les données dans un fichier JSON
✔ Afficher un tableau HTML dynamique
✔ Télécharger les PDF validés


🎁 Bonus (idées d'améliorations)

  • Ajouter un champ "description"
  • Ajouter une route pour supprimer un PDF
  • Extraire les données du tableau du PDF pour les afficher dans la page
  • Ajouter MySQL + Docker
  • Ajouter un Dockerfile

TP : Analyse de données avec Pandas

🎯 Objectif du TP

Apprendre à manipuler des données tabulaires avec Pandas :

  • Créer et modifier des DataFrames
  • Lire et écrire des fichiers CSV et Excel
  • Trier, filtrer et grouper les données
  • Ajouter des colonnes calculées
  • Calculer des statistiques simples
  • Visualiser les données

🧱 Partie 1 : Installation et préparation

📘 Consignes

  1. Créer un environnement virtuel Python.
  2. Installer Pandas et Matplotlib :
pip install pandas matplotlib seaborn openpyxl
  1. Créer un dossier data/ et y placer un fichier CSV sales.csv avec le contenu suivant :
Date,Produit,Ventes,Montant
2025-01-01,Produit A,10,250
2025-01-01,Produit B,5,125
2025-01-02,Produit A,12,300
2025-01-02,Produit B,7,175
2025-01-03,Produit A,8,200
2025-01-03,Produit B,10,250

🏗 Partie 2 : Créer et afficher un DataFrame

📘 Consignes

  1. Lire le fichier CSV avec Pandas.
  2. Afficher les 5 premières lignes.
  3. Afficher les infos et les statistiques de base.

🔧 Code

import pandas as pd

# Lire CSV
df = pd.read_csv("data/sales.csv")

# Afficher les 5 premières lignes
print(df.head())

# Informations du DataFrame
print(df.info())

# Statistiques descriptives
print(df.describe())

🔍 Partie 3 : Filtrer et trier les données

📘 Consignes

  1. Filtrer les ventes supérieures à 8.
  2. Trier les données par Montant décroissant.

🔧 Code

# Filtrer les ventes > 8
df_filtered = df[df['Ventes'] > 8]
print(df_filtered)

# Trier par Montant décroissant
df_sorted = df.sort_values(by='Montant', ascending=False)
print(df_sorted)

🧩 Partie 4 : Ajouter des colonnes calculées

📘 Consignes

  1. Ajouter une colonne Prix_Unitaire = Montant / Ventes.
  2. Ajouter une colonne Ventes_Cumulees par produit.

🔧 Code

# Prix unitaire
df['Prix_Unitaire'] = df['Montant'] / df['Ventes']

# Ventes cumulées par produit
df['Ventes_Cumulees'] = df.groupby('Produit')['Ventes'].cumsum()

print(df)

📊 Partie 5 : Groupby et statistiques

📘 Consignes

  1. Calculer le chiffre d’affaires total par produit.
  2. Calculer le nombre total de ventes par produit.

🔧 Code

# Chiffre d'affaires total par produit
total_montant = df.groupby('Produit')['Montant'].sum()
print(total_montant)

# Nombre total de ventes par produit
total_ventes = df.groupby('Produit')['Ventes'].sum()
print(total_ventes)

🖼 Partie 6 : Visualisation

📘 Consignes

  1. Créer un graphique des ventes cumulées par produit.
  2. Créer un graphique du chiffre d’affaires par date.

🔧 Code

import matplotlib.pyplot as plt
import seaborn as sns

# Ventes cumulées
plt.figure(figsize=(8,5))
sns.lineplot(data=df, x='Date', y='Ventes_Cumulees', hue='Produit', marker='o')
plt.title("Ventes cumulées par produit")
plt.show()

# Montant par date
df_grouped = df.groupby('Date')['Montant'].sum().reset_index()
plt.figure(figsize=(8,5))
sns.barplot(data=df_grouped, x='Date', y='Montant', palette="Blues_d")
plt.title("Montant total par date")
plt.show()

💾 Partie 7 : Sauvegarder les données

📘 Consignes

  1. Sauvegarder le DataFrame complet avec les colonnes calculées dans un nouveau fichier CSV.
  2. Sauvegarder également au format Excel.

🔧 Code

# CSV
df.to_csv("data/sales_processed.csv", index=False)

# Excel
df.to_excel("data/sales_processed.xlsx", index=False)

🎯 Partie 8 : Bonus / Extensions

  • Calculer la moyenne des ventes par produit.
  • Ajouter une colonne Ventes_Hier pour chaque produit.
  • Filtrer les jours où le chiffre d’affaires dépasse 300.
  • Créer un graphique combiné Montant/Ventes par produit.

TP – Reconnaissance de texte avec Python et Tesseract

Prérequis

  • Python 3.10 ou plus récent
  • Le moteur Tesseract installé sur la machine (différent du paquet Python)
  • Connaissances de base en Python : fonctions, boucles, gestion de fichiers

Installation du moteur Tesseract

# Debian / Ubuntu
sudo apt update
sudo apt install -y tesseract-ocr tesseract-ocr-fra

# macOS (Homebrew)
brew install tesseract tesseract-lang

# Windows : installeur depuis https://github.com/UB-Mannheim/tesseract/wiki

Le paquet tesseract-ocr-fra ajoute la langue française. Sans lui, seul l'anglais est disponible.

Installation des paquets Python

pip install pytesseract pillow opencv-python

Vérification

tesseract --version

Si la commande renvoie un numéro de version, le moteur est prêt.


Étape 1 – Extraire le texte d'une image simple

Sujet

On commence par le cas le plus direct : une image contenant du texte propre (capture d'écran, scan net) dont on veut récupérer le contenu sous forme de chaîne de caractères.

L'objectif est d'écrire une fonction lire_image(chemin) qui prend le chemin d'une image et renvoie le texte détecté.

Énoncé.

  1. Récupère une image contenant du texte. Une capture d'écran d'un paragraphe convient. Nomme-la exemple.png.
  2. Importe pytesseract et la classe Image de la bibliothèque PIL.
  3. Ouvre l'image avec Image.open().
  4. Passe l'objet image à pytesseract.image_to_string(). Cette fonction accepte un paramètre lang (par exemple "fra" pour le français, "eng" pour l'anglais).
  5. Affiche le résultat.

Sous Windows, si Python ne trouve pas le moteur, il faut indiquer son chemin manuellement avant l'appel :

pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"

Solution

import pytesseract
from PIL import Image


def lire_image(chemin, langue="fra"):
    image = Image.open(chemin)
    texte = pytesseract.image_to_string(image, lang=langue)
    return texte


if __name__ == "__main__":
    resultat = lire_image("exemple.png")
    print(resultat)

Explication

Image.open() charge le fichier en mémoire sous forme d'objet manipulable par Pillow. pytesseract.image_to_string() est un pont : il envoie l'image au moteur Tesseract installé sur le système et récupère le texte renvoyé sous forme de chaîne.

Le paramètre lang est important. Tesseract charge un modèle d'analyse différent selon la langue. Demander "fra" sur un texte anglais (ou l'inverse) dégrade nettement la qualité, notamment sur les accents et les mots composés.

Le texte renvoyé contient souvent des sauts de ligne \n et des espaces correspondant à la mise en page d'origine. C'est normal : Tesseract essaie de respecter la disposition du document.


Étape 2 – Améliorer la qualité avec un prétraitement

Sujet

Sur une photo prise au téléphone ou un scan de mauvaise qualité, le résultat brut est souvent décevant : lettres manquantes, mots inventés. Tesseract fonctionne nettement mieux sur une image en noir et blanc bien contrastée.

L'objectif est de préparer l'image avec OpenCV avant de la donner à Tesseract : conversion en niveaux de gris, puis seuillage pour obtenir une image binaire.

Énoncé.

  1. Importe cv2 (OpenCV) et pytesseract.
  2. Charge l'image avec cv2.imread().
  3. Convertis-la en niveaux de gris avec cv2.cvtColor() et le code cv2.COLOR_BGR2GRAY.
  4. Applique un seuillage automatique avec cv2.threshold() en utilisant la méthode d'Otsu (cv2.THRESH_BINARY + cv2.THRESH_OTSU). Cette méthode trouve seule le seuil idéal entre le texte et le fond.
  5. Passe l'image traitée à pytesseract.image_to_string().
  6. Compare le résultat avec et sans prétraitement sur une image dégradée.

À noter : OpenCV charge les images au format tableau NumPy, que pytesseract accepte directement, sans passer par Pillow.

Solution

import cv2
import pytesseract


def lire_image_traitee(chemin, langue="fra"):
    image = cv2.imread(chemin)
    gris = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    _, binaire = cv2.threshold(
        gris, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
    )
    texte = pytesseract.image_to_string(binaire, lang=langue)
    return texte


if __name__ == "__main__":
    print(lire_image_traitee("scan_degrade.png"))

Explication

La conversion en niveaux de gris supprime la couleur, inutile pour la lecture de texte et source de bruit. On passe de trois canaux (bleu, vert, rouge) à un seul canal d'intensité.

Le seuillage binaire transforme chaque pixel en blanc ou noir selon qu'il dépasse un seuil. La méthode d'Otsu calcule ce seuil automatiquement en analysant l'histogramme de l'image, ce qui évite de le régler à la main pour chaque document. Le résultat est une image propre où le texte ressort en noir sur fond blanc, exactement ce que Tesseract attend.

La valeur _ ignore le premier élément renvoyé par cv2.threshold() (le seuil calculé), dont on n'a pas besoin ici.

Sur une image déjà nette, ce prétraitement change peu de chose. Sur une photo avec des ombres ou un éclairage irrégulier, il fait souvent la différence entre un texte exploitable et du charabia.


Étape 3 – Récupérer la position des mots

Sujet

Parfois le texte ne suffit pas : on veut savoir chaque mot se trouve dans l'image, par exemple pour surligner un résultat ou découper une zone précise.

L'objectif est d'utiliser pytesseract.image_to_data() pour obtenir, pour chaque mot, ses coordonnées et son niveau de confiance, puis de dessiner un rectangle autour des mots détectés.

Énoncé.

  1. Charge l'image avec OpenCV.
  2. Appelle pytesseract.image_to_data() avec le paramètre output_type=pytesseract.Output.DICT. Cela renvoie un dictionnaire de listes parallèles : text, left, top, width, height, conf (confiance).
  3. Parcours les mots. Pour chacun, ignore les entrées vides et celles dont la confiance (conf) est inférieure à 60.
  4. Pour les mots retenus, dessine un rectangle avec cv2.rectangle() à partir de left, top, width, height.
  5. Enregistre l'image annotée avec cv2.imwrite().

Le champ conf est une chaîne ou un nombre selon les versions ; convertis-le en int avant comparaison. Une valeur de -1 signifie qu'il ne s'agit pas d'un mot (ligne ou bloc).

Solution

import cv2
import pytesseract


def annoter_mots(chemin, sortie="annote.png", langue="fra", seuil_conf=60):
    image = cv2.imread(chemin)
    donnees = pytesseract.image_to_data(
        image, lang=langue, output_type=pytesseract.Output.DICT
    )

    nb_mots = len(donnees["text"])
    for i in range(nb_mots):
        mot = donnees["text"][i].strip()
        confiance = int(donnees["conf"][i])

        if mot == "" or confiance < seuil_conf:
            continue

        x = donnees["left"][i]
        y = donnees["top"][i]
        w = donnees["width"][i]
        h = donnees["height"][i]
        cv2.rectangle(image, (x, y), (x + w, y + h), (0, 255, 0), 2)

    cv2.imwrite(sortie, image)
    print(f"Image annotée enregistrée : {sortie}")


if __name__ == "__main__":
    annoter_mots("exemple.png")

Explication

image_to_data() expose le détail du travail de Tesseract, là où image_to_string() ne donnait que le texte assemblé. Le dictionnaire renvoyé contient une entrée par élément détecté, et toutes les listes ont la même longueur : l'indice i désigne le même mot dans text, left, conf, etc. C'est pour ça qu'on les parcourt avec un seul range.

Le filtre sur la confiance sert à écarter le bruit. Tesseract attribue une confiance basse aux zones où il hésite (taches, bordures, artefacts). Garder uniquement les mots au-dessus de 60 évite de couvrir l'image de rectangles parasites. Le seuil reste un compromis à ajuster selon la qualité des documents.

Les coordonnées suivent la convention image : l'origine (0, 0) est en haut à gauche, x augmente vers la droite, y vers le bas. Le coin opposé du rectangle se calcule donc en ajoutant largeur et hauteur au coin supérieur gauche.


Étape 4 – Traiter un lot d'images et exporter les résultats

Sujet

En situation réelle, on traite rarement une seule image. On veut parcourir un dossier de scans et produire un fichier récapitulatif.

L'objectif est de lire toutes les images d'un dossier, d'en extraire le texte, et d'écrire chaque résultat dans un fichier CSV avec le nom du fichier et le texte associé.

Énoncé.

  1. Utilise le module pathlib pour lister les fichiers du dossier images/.
  2. Ne garde que les extensions image courantes : .png, .jpg, .jpeg, .tiff.
  3. Pour chaque image, extrais le texte (réutilise la fonction de l'étape 1 ou 2).
  4. Écris les résultats dans resultats.csv avec deux colonnes : fichier et texte. Utilise le module csv.
  5. Le texte peut contenir des sauts de ligne et des virgules : le module csv gère l'échappement automatiquement, à condition d'utiliser son writer plutôt que de concaténer les chaînes à la main.

Solution

import csv
from pathlib import Path

import pytesseract
from PIL import Image

EXTENSIONS = {".png", ".jpg", ".jpeg", ".tiff"}


def lire_image(chemin, langue="fra"):
    image = Image.open(chemin)
    return pytesseract.image_to_string(image, lang=langue)


def traiter_dossier(dossier="images", sortie="resultats.csv"):
    chemin_dossier = Path(dossier)
    images = [
        f for f in chemin_dossier.iterdir()
        if f.suffix.lower() in EXTENSIONS
    ]

    with open(sortie, "w", newline="", encoding="utf-8") as fichier_csv:
        writer = csv.writer(fichier_csv)
        writer.writerow(["fichier", "texte"])

        for image in sorted(images):
            texte = lire_image(image).strip()
            writer.writerow([image.name, texte])
            print(f"Traité : {image.name}")

    print(f"\n{len(images)} image(s) exportée(s) dans {sortie}")


if __name__ == "__main__":
    traiter_dossier()

Explication

Path.iterdir() liste le contenu du dossier sous forme d'objets Path, plus pratiques que de simples chaînes : l'attribut .suffix donne l'extension et .name le nom de fichier seul. Le .lower() sur l'extension permet de capter aussi bien .JPG que .jpg.

Le set EXTENSIONS rend le test d'appartenance rapide et lisible. On filtre dès la construction de la liste pour ignorer les fichiers non-image éventuellement présents dans le dossier.

L'ouverture du CSV avec newline="" et encoding="utf-8" est la combinaison recommandée par la documentation Python : newline="" évite des lignes vides parasites sous Windows, et l'encodage UTF-8 garantit que les accents sont correctement enregistrés. Le csv.writer se charge d'entourer de guillemets les champs contenant virgules ou retours à la ligne, ce qu'une concaténation manuelle gérerait mal.

Le sorted() sur la liste d'images donne un ordre de traitement stable et reproductible, utile pour comparer deux exécutions.


Pour aller plus loin

  • Tester le paramètre config de image_to_string, par exemple --psm 6 pour forcer le mode « bloc de texte uniforme », utile sur des tickets ou des tableaux.
  • Comparer les résultats sur une même image avec et sans la langue correcte.
  • Mesurer le temps de traitement sur un gros lot et voir l'effet du prétraitement sur la vitesse.
  • Gérer les PDF : les convertir en images avec pdf2image avant de les passer à Tesseract.

TP – Gérer les environnements virtuels Python

Prérequis

  • Python 3.10 ou plus récent
  • Un terminal (PowerShell, bash ou zsh)
  • Connaissances de base en ligne de commande : se déplacer dans les dossiers, exécuter une commande

Pourquoi des environnements virtuels

Quand on installe un paquet avec pip sans précaution, il s'installe au niveau du système, pour tous les projets à la fois. Deux problèmes apparaissent vite : deux projets qui ont besoin de versions différentes d'une même bibliothèque entrent en conflit, et la liste des dépendances d'un projet devient impossible à isoler.

Un environnement virtuel est un dossier qui contient une copie isolée de Python et de ses paquets. Chaque projet a le sien. Ce qu'on installe dedans n'affecte que ce projet.

Vérification

python --version
python -m venv --help

Sous certains systèmes, la commande est python3 plutôt que python. Le module venv est inclus dans la bibliothèque standard depuis Python 3.3, rien à installer.


Étape 1 – Créer et activer un environnement

Sujet

On part d'un dossier de projet vide et on y crée un environnement virtuel isolé, puis on l'active pour que les commandes python et pip pointent vers cet environnement plutôt que vers le système.

L'objectif est de créer l'environnement, de l'activer, et de vérifier qu'on travaille bien dedans.

Énoncé.

  1. Crée un dossier de projet et place-toi dedans.
  2. Crée un environnement virtuel nommé .venv avec la commande python -m venv .venv. Le .venv est un nom conventionnel, le point en début le rend discret dans les listings.
  3. Active l'environnement. La commande dépend du système :
    • Linux / macOS (bash, zsh) : source .venv/bin/activate
    • Windows (PowerShell) : .venv\Scripts\Activate.ps1
    • Windows (cmd) : .venv\Scripts\activate.bat
  4. Vérifie l'activation : le nom (.venv) apparaît au début de l'invite du terminal.
  5. Confirme que Python pointe bien vers l'environnement avec which python (Linux/macOS) ou where python (Windows). Le chemin doit contenir .venv.

Solution

# Création du dossier de projet
mkdir mon_projet
cd mon_projet

# Création de l'environnement
python -m venv .venv

# Activation (Linux / macOS)
source .venv/bin/activate

# Vérification
which python
# Sortie attendue : /chemin/vers/mon_projet/.venv/bin/python

Sous Windows PowerShell :

mkdir mon_projet
cd mon_projet
python -m venv .venv
.venv\Scripts\Activate.ps1
where python

Si PowerShell refuse d'exécuter le script d'activation avec une erreur de politique d'exécution, autorise les scripts pour la session courante :

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

Explication

python -m venv .venv crée un dossier .venv contenant un interpréteur Python, le gestionnaire pip, et la structure pour accueillir des paquets. Tant que l'environnement n'est pas activé, ce contenu reste inerte.

L'activation ne fait rien de magique : elle modifie temporairement la variable PATH du terminal pour placer le dossier de l'environnement en premier. Du coup, taper python ou pip exécute les versions de l'environnement, pas celles du système. C'est pour ça que la modification ne vaut que pour le terminal courant et disparaît à sa fermeture.

Le (.venv) dans l'invite est un repère visuel ajouté par le script d'activation. C'est le moyen le plus rapide de savoir si on est dans un environnement ou non avant de lancer un pip install.

Pour sortir de l'environnement, la commande deactivate fonctionne sur tous les systèmes et restaure le PATH d'origine.


Étape 2 – Installer des paquets et figer les versions

Sujet

Une fois l'environnement actif, on y installe les paquets du projet. Pour qu'un collègue (ou soi-même plus tard) puisse recréer le même environnement à l'identique, on enregistre la liste des dépendances avec leurs versions exactes.

L'objectif est d'installer quelques paquets, puis de générer un fichier requirements.txt.

Énoncé.

  1. Avec l'environnement activé, installe deux paquets, par exemple requests et rich : pip install requests rich.
  2. Liste les paquets installés avec pip list. Tu verras requests, rich, mais aussi leurs dépendances installées automatiquement.
  3. Génère le fichier de dépendances avec pip freeze > requirements.txt.
  4. Ouvre requirements.txt et observe le format : chaque ligne est un paquet suivi de == et de sa version exacte.
  5. Installe une version précise d'un paquet pour voir l'effet : pip install "requests==2.31.0", puis regénère le fichier.

Solution

# Environnement activé
pip install requests rich

# Liste de contrôle
pip list

# Figer les versions
pip freeze > requirements.txt

cat requirements.txt

Contenu typique de requirements.txt :

certifi==2024.7.4
charset-normalizer==3.3.2
idna==3.7
markdown-it-py==3.0.0
mdurl==0.1.2
Pygments==2.18.0
requests==2.32.3
rich==13.7.1
urllib3==2.2.2

Pour épingler une version précise :

pip install "requests==2.31.0"
pip freeze > requirements.txt

Explication

pip install requests rich installe les deux paquets demandés et tout ce dont ils ont besoin pour fonctionner. C'est pour ça que pip list affiche plus de lignes que les deux paquets demandés : requests dépend de certifi, urllib3, etc. Ces dépendances indirectes sont installées dans le même environnement isolé.

pip freeze produit la liste complète au format paquet==version. La différence avec pip list est le format : freeze est conçu pour être réinjecté, list pour être lu. Le double signe égal fige la version au numéro exact, ce qui rend l'installation reproductible : la commande de l'étape 3 réinstallera précisément ces versions, pas les plus récentes.

Inclure les dépendances indirectes dans requirements.txt est un choix volontaire. Cela garantit que tout le monde obtient exactement le même arbre de dépendances, jusqu'aux versions des sous-paquets. L'inconvénient est un fichier plus long et moins lisible ; il existe d'autres outils (pip-tools, Poetry) pour séparer dépendances directes et indirectes, mais pip freeze reste le socle commun à connaître.


Étape 3 – Recréer un environnement à partir de requirements.txt

Sujet

On simule l'arrivée sur un projet existant : on a le code et un requirements.txt, mais pas l'environnement. Il faut le reconstruire à l'identique.

L'objectif est de partir d'un environnement supprimé et de le recréer entièrement depuis le fichier de dépendances.

Énoncé.

  1. Désactive l'environnement courant avec deactivate.
  2. Supprime le dossier .venv (c'est sans risque, il se recrée). Sous Linux/macOS : rm -rf .venv. Sous Windows : Remove-Item -Recurse -Force .venv.
  3. Recrée un environnement vierge : python -m venv .venv, puis active-le.
  4. Vérifie qu'il est vide avec pip list : seuls pip et éventuellement setuptools apparaissent.
  5. Réinstalle toutes les dépendances d'un coup : pip install -r requirements.txt.
  6. Confirme avec pip list que les paquets sont revenus.

Solution

# Sortir de l'environnement
deactivate

# Supprimer le dossier (Linux / macOS)
rm -rf .venv

# Recréer et activer
python -m venv .venv
source .venv/bin/activate

# Contrôle : environnement vide
pip list

# Réinstaller depuis le fichier
pip install -r requirements.txt

# Contrôle final
pip list

Explication

Supprimer .venv est anodin parce que l'environnement est entièrement reconstructible à partir de requirements.txt. C'est un principe important : le dossier d'environnement ne doit jamais contenir de code unique du projet, seulement des paquets installés. Pour cette raison, on ne le sauvegarde pas et on l'exclut du dépôt Git (voir étape 4).

L'option -r de pip install indique de lire un fichier de dépendances ligne par ligne plutôt que de prendre un nom de paquet. Chaque ligne du fichier est traitée comme un pip install individuel, avec la version exacte épinglée par le ==.

Ce cycle supprimer / recréer / réinstaller est exactement ce que fait un système d'intégration continue ou un collègue qui clone le projet. Si la reconstruction fonctionne à partir du seul requirements.txt, le projet est correctement portable. Si elle échoue, c'est souvent qu'un paquet a été installé manuellement sans être ajouté au fichier.


Étape 4 – Bonnes pratiques d'organisation

Sujet

Pour qu'un projet reste sain dans la durée, quelques règles d'organisation évitent les erreurs courantes : ne pas versionner l'environnement, documenter les commandes, repérer les dépendances obsolètes.

L'objectif est de mettre en place un .gitignore correct et de connaître les commandes de maintenance.

Énoncé.

  1. Crée un fichier .gitignore à la racine du projet et ajoute-y la ligne qui exclut le dossier d'environnement : .venv/.
  2. Ajoute aussi les fichiers de cache Python : __pycache__/ et *.pyc.
  3. Repère les paquets obsolètes avec pip list --outdated.
  4. Mets à jour un paquet précis : pip install --upgrade rich, puis regénère requirements.txt.
  5. Pour vérifier qu'un environnement n'a pas de dépendances cassées, lance pip check.

Solution

Contenu du fichier .gitignore :

# Environnement virtuel
.venv/

# Cache Python
__pycache__/
*.pyc
*.pyo

# Fichiers d'environnement local
.env

Commandes de maintenance :

# Lister les paquets dépassés
pip list --outdated

# Mettre à jour un paquet et refiger les versions
pip install --upgrade rich
pip freeze > requirements.txt

# Vérifier la cohérence des dépendances
pip check

Explication

Exclure .venv/ du dépôt Git est non négociable. Le dossier peut peser des centaines de mégaoctets, il est propre à chaque système d'exploitation (un .venv créé sous Windows ne fonctionne pas sous Linux), et il est de toute façon reconstructible depuis requirements.txt. Ce qu'on versionne, c'est le code et le fichier de dépendances, pas l'environnement lui-même.

Les dossiers __pycache__ contiennent le bytecode compilé que Python génère automatiquement à l'exécution. Comme l'environnement, ils se régénèrent seuls et n'ont aucune raison d'être suivis.

pip list --outdated compare les versions installées aux dernières disponibles sur PyPI et ne montre que ce qui peut être mis à jour. Mettre à jour est utile pour les correctifs de sécurité, mais chaque mise à jour est une prise de risque : il faut regénérer requirements.txt après, et tester que le projet fonctionne toujours.

pip check parcourt les paquets installés et signale les incompatibilités, par exemple un paquet qui exige une version d'une dépendance différente de celle réellement installée. C'est un contrôle rapide à lancer après une série d'installations ou de mises à jour.


Pour aller plus loin

  • Comparer venv avec d'autres gestionnaires : virtualenv, Poetry, pipenv, uv. Chacun résout le même problème de base avec des compromis différents.
  • Découvrir python -m venv --prompt mon-projet .venv pour personnaliser le nom affiché dans l'invite.
  • Examiner ce que contient réellement le dossier .venv (le sous-dossier bin ou Scripts, le fichier pyvenv.cfg).
  • Tester un même requirements.txt sur deux versions de Python différentes et observer les éventuels écarts.

TP – Implémenter un cache LRU en Python

Prérequis

  • Python 3.10 ou plus récent
  • Connaissances : classes, dictionnaires, décorateurs (notions de base)
  • Un terminal pour exécuter les scripts

De quoi parle-t-on

Un cache garde en mémoire le résultat d'opérations coûteuses pour ne pas les recalculer. Mais la mémoire n'est pas infinie : il faut une règle pour décider quoi jeter quand le cache est plein.

LRU signifie Least Recently Used, « le moins récemment utilisé ». La règle : quand le cache atteint sa taille maximale et qu'on veut y ajouter un élément, on supprime celui qui n'a pas été consulté depuis le plus longtemps. L'idée est qu'un élément qu'on n'a pas touché récemment a peu de chances de resservir bientôt.

Vérification

python --version

Étape 1 – Utiliser le cache LRU de la bibliothèque standard

Sujet

Avant de construire un cache à la main, on utilise celui que Python fournit. Le décorateur functools.lru_cache transforme n'importe quelle fonction en version qui mémorise ses résultats.

L'objectif est de mesurer concrètement le gain sur une fonction lente, ici le calcul récursif de la suite de Fibonacci, qui recalcule sans cesse les mêmes valeurs.

Énoncé.

  1. Écris une fonction récursive fibonacci(n) sans cache. Pour n autour de 35, elle devient déjà lente.
  2. Mesure son temps d'exécution avec le module time.
  3. Crée une seconde version décorée avec @lru_cache(maxsize=None), importé depuis functools.
  4. Mesure son temps et compare.
  5. Affiche les statistiques du cache avec la méthode .cache_info() ajoutée par le décorateur.

Solution

import time
from functools import lru_cache


def fibonacci_lent(n):
    if n < 2:
        return n
    return fibonacci_lent(n - 1) + fibonacci_lent(n - 2)


@lru_cache(maxsize=None)
def fibonacci_cache(n):
    if n < 2:
        return n
    return fibonacci_cache(n - 1) + fibonacci_cache(n - 2)


if __name__ == "__main__":
    debut = time.perf_counter()
    fibonacci_lent(35)
    print(f"Sans cache : {time.perf_counter() - debut:.4f} s")

    debut = time.perf_counter()
    fibonacci_cache(35)
    print(f"Avec cache : {time.perf_counter() - debut:.6f} s")

    print(fibonacci_cache.cache_info())

Explication

La version sans cache est lente parce qu'elle recalcule des milliers de fois les mêmes valeurs : fibonacci(35) rappelle fibonacci(34) et fibonacci(33), qui rappellent à leur tour fibonacci(32), et ainsi de suite. Le nombre d'appels explose de façon exponentielle.

Le décorateur @lru_cache intercepte chaque appel. Il regarde si la fonction a déjà été appelée avec ces arguments. Si oui, il renvoie le résultat mémorisé sans exécuter le corps de la fonction. Sinon, il exécute, stocke le résultat, et le renvoie. Pour Fibonacci, chaque valeur n'est donc calculée qu'une seule fois, ce qui ramène le temps de quasi-rien.

maxsize=None indique un cache sans limite de taille : on garde tout. C'est adapté à Fibonacci où le nombre de valeurs distinctes est petit. Avec une valeur comme maxsize=128, le décorateur applique justement la politique LRU et jette les entrées les moins récemment utilisées au-delà de 128.

.cache_info() renvoie le nombre de hits (réponses trouvées dans le cache) et de misses (calculs réels). Un ratio de hits élevé confirme que le cache travaille.


Étape 2 – Construire un cache LRU à la main avec OrderedDict

Sujet

Pour comprendre ce que fait lru_cache sous le capot, on le reconstruit. La structure clé est collections.OrderedDict, un dictionnaire qui se souvient de l'ordre d'insertion de ses clés et permet de déplacer une clé en fin de file.

L'objectif est d'écrire une classe CacheLRU avec deux méthodes : get(cle) et put(cle, valeur), respectant une taille maximale.

Énoncé.

  1. Crée une classe CacheLRU dont le constructeur reçoit une capacite et initialise un OrderedDict vide.
  2. Méthode get(cle) : si la clé est absente, renvoie None. Si elle est présente, déplace-la en fin de file avec move_to_end(cle) puis renvoie sa valeur. Déplacer la clé la marque comme « récemment utilisée ».
  3. Méthode put(cle, valeur) : si la clé existe déjà, mets à jour sa valeur et déplace-la en fin. Sinon, ajoute-la. Si le cache dépasse alors sa capacité, supprime la plus ancienne avec popitem(last=False).
  4. Teste avec une capacité de 2 : ajoute trois éléments et vérifie que le premier a bien disparu.

Solution

from collections import OrderedDict


class CacheLRU:
    def __init__(self, capacite):
        self.capacite = capacite
        self.cache = OrderedDict()

    def get(self, cle):
        if cle not in self.cache:
            return None
        self.cache.move_to_end(cle)
        return self.cache[cle]

    def put(self, cle, valeur):
        if cle in self.cache:
            self.cache.move_to_end(cle)
        self.cache[cle] = valeur
        if len(self.cache) > self.capacite:
            self.cache.popitem(last=False)


if __name__ == "__main__":
    cache = CacheLRU(capacite=2)
    cache.put("a", 1)
    cache.put("b", 2)
    print(cache.get("a"))   # 1 -> "a" devient le plus récent
    cache.put("c", 3)       # capacité dépassée -> "b" est évincé
    print(cache.get("b"))   # None, "b" a été supprimé
    print(cache.get("c"))   # 3

Explication

OrderedDict est le bon choix parce qu'il combine deux propriétés : l'accès à une clé est immédiat (comme un dictionnaire normal), et l'ordre des clés est maîtrisable. La file va du moins récent (au début) au plus récent (à la fin).

move_to_end(cle) est le cœur de la logique LRU. Chaque fois qu'on lit ou met à jour une clé, on la repousse en fin de file. Les clés qui restent au début sont donc celles qu'on n'a pas touchées depuis longtemps : ce sont les premières candidates à l'éviction.

popitem(last=False) retire l'élément situé au début de la file, c'est-à-dire le moins récemment utilisé. Le paramètre last=False est important : par défaut popitem retire le dernier élément, ici on veut le premier.

L'ordre des opérations dans put compte. On insère ou met à jour d'abord, puis on vérifie le dépassement. Comme l'élément qu'on vient d'ajouter est en fin de file, il ne risque jamais d'être celui qu'on évince juste après.


Étape 3 – Mesurer l'efficacité avec hits et misses

Sujet

Un cache n'est utile que si son taux de réussite est correct. On enrichit la classe pour qu'elle compte les accès trouvés (hits) et manqués (misses), comme le fait lru_cache.

L'objectif est d'ajouter des compteurs et une méthode pour afficher le taux de réussite.

Énoncé.

  1. Dans le constructeur, ajoute deux attributs self.hits = 0 et self.misses = 0.
  2. Dans get, incrémente misses quand la clé est absente, hits quand elle est présente.
  3. Ajoute une méthode statistiques() qui renvoie le nombre de hits, de misses, et le taux de réussite (hits divisés par le total des accès). Gère le cas où aucun accès n'a encore eu lieu pour éviter une division par zéro.
  4. Teste sur une série d'accès et affiche les statistiques.

Solution

from collections import OrderedDict


class CacheLRU:
    def __init__(self, capacite):
        self.capacite = capacite
        self.cache = OrderedDict()
        self.hits = 0
        self.misses = 0

    def get(self, cle):
        if cle not in self.cache:
            self.misses += 1
            return None
        self.hits += 1
        self.cache.move_to_end(cle)
        return self.cache[cle]

    def put(self, cle, valeur):
        if cle in self.cache:
            self.cache.move_to_end(cle)
        self.cache[cle] = valeur
        if len(self.cache) > self.capacite:
            self.cache.popitem(last=False)

    def statistiques(self):
        total = self.hits + self.misses
        taux = self.hits / total if total else 0.0
        return {
            "hits": self.hits,
            "misses": self.misses,
            "taux_reussite": round(taux, 2),
        }


if __name__ == "__main__":
    cache = CacheLRU(capacite=2)
    for cle in ["a", "b", "a", "c", "a", "b"]:
        if cache.get(cle) is None:
            cache.put(cle, cle.upper())
    print(cache.statistiques())

Explication

Les compteurs sont mis à jour exactement aux deux points de décision de get : la branche « clé absente » incrémente misses, la branche « clé présente » incrémente hits. On ne touche pas aux compteurs dans put, car ajouter une valeur n'est ni une réussite ni un échec de lecture.

Le taux de réussite est l'indicateur le plus parlant. Un taux proche de 1 signifie que le cache répond presque toujours sans recalcul. Un taux faible indique soit une capacité trop petite, soit un motif d'accès où les mêmes clés reviennent rarement, auquel cas le cache n'apporte pas grand-chose.

La protection contre la division par zéro (if total else 0.0) est un réflexe à avoir dès qu'on calcule une moyenne ou un ratio : tant qu'aucun accès n'a eu lieu, le total vaut zéro et la division planterait.

Le motif d'accès du test, a b a c a b, est volontairement répétitif pour produire des hits. Avec une capacité de 2 et a consulté souvent, on voit l'intérêt du LRU : a reste dans le cache parce qu'on le réutilise, alors que les clés moins sollicitées sont évincées.


Étape 4 – Transformer le cache en décorateur réutilisable

Sujet

Pour rendre le cache pratique, on l'enveloppe dans un décorateur, comme lru_cache. On pourra alors mettre en cache n'importe quelle fonction simplement en la décorant.

L'objectif est d'écrire un décorateur cache_lru(capacite) qui utilise la classe des étapes précédentes pour mémoriser les résultats d'une fonction selon ses arguments.

Énoncé.

  1. Écris une fonction cache_lru(capacite) qui renvoie un décorateur. C'est un décorateur paramétré : trois niveaux de fonctions imbriquées.
  2. À l'intérieur, crée une instance de CacheLRU qui servira de mémoire pour la fonction décorée.
  3. La fonction interne wrapper construit une clé à partir des arguments reçus, regarde dans le cache, et appelle la fonction réelle seulement en cas d'absence.
  4. Les arguments servant de clé doivent être hachables : utilise un tuple des args. Pour rester simple, on ignore ici les arguments nommés.
  5. Décore la fonction Fibonacci de l'étape 1 et vérifie que le cache fonctionne.

Solution

from collections import OrderedDict
from functools import wraps


class CacheLRU:
    def __init__(self, capacite):
        self.capacite = capacite
        self.cache = OrderedDict()

    def get(self, cle):
        if cle not in self.cache:
            return None
        self.cache.move_to_end(cle)
        return self.cache[cle]

    def put(self, cle, valeur):
        if cle in self.cache:
            self.cache.move_to_end(cle)
        self.cache[cle] = valeur
        if len(self.cache) > self.capacite:
            self.cache.popitem(last=False)


def cache_lru(capacite):
    def decorateur(fonction):
        memoire = CacheLRU(capacite)

        @wraps(fonction)
        def wrapper(*args):
            resultat = memoire.get(args)
            if resultat is not None:
                return resultat
            resultat = fonction(*args)
            memoire.put(args, resultat)
            return resultat

        return wrapper
    return decorateur


@cache_lru(capacite=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


if __name__ == "__main__":
    print(fibonacci(35))

Explication

Un décorateur qui prend un paramètre a trois niveaux. cache_lru(capacite) reçoit l'argument et renvoie decorateur. decorateur(fonction) reçoit la fonction à décorer et renvoie wrapper. wrapper(*args) est ce qui s'exécute réellement à chaque appel. Chaque niveau capture ce dont le niveau intérieur a besoin : la capacité, puis la fonction, puis les arguments.

L'instance memoire est créée une seule fois, au moment où la fonction est décorée, pas à chaque appel. Grâce à la fermeture (closure), le wrapper garde accès à cette même instance pour toute la durée de vie de la fonction décorée. C'est ce qui permet au cache de persister entre les appels.

La clé du cache est le tuple args. Un tuple est hachable, donc utilisable comme clé de dictionnaire, à condition que les arguments eux-mêmes le soient (nombres, chaînes, tuples). C'est pour ça qu'on ignore les arguments nommés ici : les gérer correctement demanderait de les normaliser, ce qui sort du cadre de ce TP.

@wraps(fonction) recopie le nom et la docstring de la fonction d'origine sur le wrapper. Sans lui, la fonction décorée s'appellerait wrapper partout, ce qui gêne le débogage et la documentation. C'est une bonne pratique systématique quand on écrit un décorateur.

Une limite à connaître : le test if resultat is not None confond une valeur réellement absente avec une fonction qui renvoie légitimement None. La vraie lru_cache utilise un objet sentinelle unique pour distinguer les deux cas ; c'est une amélioration possible.


Pour aller plus loin

  • Distinguer une valeur None mise en cache d'une absence, en utilisant un objet sentinelle (absent = object()).
  • Gérer les arguments nommés dans le décorateur, par exemple en construisant la clé à partir de args et d'un tuple trié des kwargs.
  • Comparer les performances du cache maison avec functools.lru_cache sur un grand nombre d'accès.
  • Découvrir functools.cache (Python 3.9+), un raccourci pour lru_cache(maxsize=None).
  • Étudier d'autres politiques d'éviction : LFU (le moins fréquemment utilisé), FIFO (premier entré premier sorti), et leurs cas d'usage.

TP – Valider des données avec Pydantic

Prérequis

  • Python 3.9 ou plus récent
  • Connaissances : classes, annotations de type (int, str, list), dictionnaires
  • Un environnement virtuel actif (voir le TP sur les venv)

À quoi sert Pydantic

Quand un programme reçoit des données de l'extérieur (formulaire web, fichier JSON, réponse d'API), rien ne garantit qu'elles sont correctes : un champ peut manquer, un nombre arriver sous forme de texte, un type être inattendu. Vérifier tout ça à la main est long et source d'erreurs.

Pydantic fait ce travail à partir des annotations de type. On décrit la forme attendue des données avec une classe, et Pydantic se charge de valider, de convertir quand c'est possible, et de signaler clairement ce qui ne va pas. C'est la bibliothèque de validation la plus utilisée en Python, et le socle d'outils comme FastAPI.

Installation

pip install pydantic

Vérification

python -c "import pydantic; print(pydantic.VERSION)"

Ce TP suit Pydantic version 2.


Étape 1 – Définir un premier modèle

Sujet

On crée une classe qui décrit la structure d'un utilisateur : un identifiant, un nom, une date d'inscription. Pydantic valide automatiquement les données qu'on lui passe et convertit les types compatibles.

L'objectif est de définir un modèle en héritant de BaseModel et de l'instancier avec un dictionnaire de données.

Énoncé.

  1. Importe BaseModel depuis pydantic.
  2. Crée une classe Utilisateur qui hérite de BaseModel, avec les champs suivants annotés par leur type :
    • id de type int
    • nom de type str, avec la valeur par défaut "Anonyme"
    • actif de type bool, avec la valeur par défaut True
  3. Un champ annoté sans valeur par défaut est obligatoire ; un champ avec valeur par défaut est facultatif.
  4. Crée une instance en passant un dictionnaire en arguments nommés : Utilisateur(**donnees).
  5. Observe que Pydantic convertit les types compatibles : passe id sous forme de chaîne "123" et constate qu'il devient un entier.
  6. Affiche l'objet, puis sa version dictionnaire avec .model_dump().

Solution

from pydantic import BaseModel


class Utilisateur(BaseModel):
    id: int
    nom: str = "Anonyme"
    actif: bool = True


donnees = {"id": "123", "nom": "Paul"}

utilisateur = Utilisateur(**donnees)

print(utilisateur)
# id=123 nom='Paul' actif=True

print(utilisateur.id, type(utilisateur.id))
# 123 <class 'int'>

print(utilisateur.model_dump())
# {'id': 123, 'nom': 'Paul', 'actif': True}

Explication

Hériter de BaseModel est ce qui transforme une classe ordinaire en modèle Pydantic. À partir des annotations de type des champs, Pydantic construit en coulisses un validateur qui s'exécute à chaque création d'instance. On ne déclare pas de __init__ : Pydantic le génère.

La distinction obligatoire/facultatif tient à la présence d'une valeur par défaut. id: int sans valeur signifie « ce champ doit être fourni ». nom: str = "Anonyme" signifie « si rien n'est fourni, ce sera Anonyme ». C'est pour ça qu'on a pu omettre actif dans les données.

La conversion de "123" en 123 illustre le mode souple (lax) de Pydantic : quand une donnée n'a pas le bon type mais peut être convertie sans ambiguïté, Pydantic la convertit. Une chaîne numérique devient un entier, mais une chaîne comme "bonjour" déclencherait une erreur, car elle ne représente aucun entier.

.model_dump() fait l'opération inverse de la création : il transforme le modèle en dictionnaire Python standard, pratique pour sérialiser ou transmettre les données validées.


Étape 2 – Gérer les erreurs de validation

Sujet

L'intérêt de Pydantic se révèle quand les données sont mauvaises. Plutôt que de laisser passer une erreur silencieuse, il lève une exception détaillée qui liste tout ce qui ne va pas.

L'objectif est de provoquer une erreur de validation et de l'intercepter pour en lire le détail.

Énoncé.

  1. Reprends le modèle Utilisateur de l'étape 1.
  2. Importe ValidationError depuis pydantic.
  3. Tente de créer un utilisateur avec des données invalides : un id non convertible en entier (par exemple "pas un nombre") et le champ obligatoire id carrément absent dans un second test.
  4. Entoure la création d'un bloc try / except ValidationError.
  5. Dans le except, affiche le détail avec la méthode .errors() de l'exception, qui renvoie une liste de dictionnaires (un par erreur).

Solution

from pydantic import BaseModel, ValidationError


class Utilisateur(BaseModel):
    id: int
    nom: str = "Anonyme"
    actif: bool = True


donnees_invalides = {"id": "pas un nombre"}

try:
    Utilisateur(**donnees_invalides)
except ValidationError as erreur:
    for detail in erreur.errors():
        print(detail["loc"], "->", detail["msg"])
    # ('id',) -> Input should be a valid integer, unable to parse string as an integer

Avec un champ obligatoire manquant :

try:
    Utilisateur(nom="Paul")   # id absent
except ValidationError as erreur:
    print(erreur.errors())
    # [{'type': 'missing', 'loc': ('id',), 'msg': 'Field required', ...}]

Explication

Pydantic ne s'arrête pas à la première erreur : il valide tous les champs et rassemble tous les problèmes dans une seule ValidationError. Si trois champs sont mauvais, on obtient les trois d'un coup, ce qui évite de corriger une erreur pour en découvrir une autre à l'exécution suivante.

.errors() renvoie une liste structurée plutôt qu'un simple texte. Chaque entrée contient loc (l'emplacement du champ fautif, sous forme de tuple), msg (le message lisible), type (un code d'erreur exploitable par programme) et la valeur reçue. Cette structure est précieuse : on peut par exemple renvoyer ces erreurs à une interface web sous forme de messages par champ.

Le type missing distingue un champ absent d'un champ présent mais invalide (int_parsing). Cette différence permet de traiter les deux cas séparément si besoin, par exemple distinguer « tu as oublié de remplir » de « tu as mal rempli ».

Intercepter ValidationError plutôt que de laisser l'exception remonter est la bonne pratique aux frontières du programme : à l'endroit où les données extérieures entrent, on valide et on décide quoi faire en cas d'échec.


Étape 3 – Types imbriqués et contraintes

Sujet

Les données réelles sont rarement plates : un utilisateur possède une adresse, une liste de centres d'intérêt, etc. Pydantic gère les modèles imbriqués et les types composés, et permet d'ajouter des contraintes (valeur positive, longueur minimale).

L'objectif est de construire un modèle Commande contenant un modèle Client imbriqué, une liste d'articles, et des champs contraints.

Énoncé.

  1. Importe BaseModel, Field et PositiveInt depuis pydantic.
  2. Crée un modèle Client avec nom (chaîne) et email (chaîne).
  3. Crée un modèle Commande avec :
    • numero de type int
    • client de type Client (modèle imbriqué)
    • articles de type list[str]
    • quantite de type PositiveInt (entier strictement positif)
    • remise de type float, contraint entre 0 et 1 avec Field(ge=0, le=1) (ge = supérieur ou égal, le = inférieur ou égal)
  4. Crée une commande à partir d'un dictionnaire où client est lui-même un dictionnaire. Pydantic construit l'objet Client automatiquement.
  5. Teste qu'une quantite de 0 ou une remise de 1.5 lève bien une erreur.

Solution

from pydantic import BaseModel, Field, PositiveInt, ValidationError


class Client(BaseModel):
    nom: str
    email: str


class Commande(BaseModel):
    numero: int
    client: Client
    articles: list[str]
    quantite: PositiveInt
    remise: float = Field(ge=0, le=1)


donnees = {
    "numero": 1001,
    "client": {"nom": "Paul", "email": "paul@example.com"},
    "articles": ["clavier", "souris"],
    "quantite": 3,
    "remise": 0.2,
}

commande = Commande(**donnees)
print(commande.client.nom)   # Paul -> objet Client construit automatiquement
print(commande.model_dump())

# Contraintes violées
try:
    Commande(numero=2, client={"nom": "X", "email": "x@x.fr"},
             articles=[], quantite=0, remise=1.5)
except ValidationError as erreur:
    for detail in erreur.errors():
        print(detail["loc"], "->", detail["msg"])
    # ('quantite',) -> Input should be greater than 0
    # ('remise',)   -> Input should be less than or equal to 1

Explication

L'imbrication est automatique : parce que le champ client est annoté avec le type Client, Pydantic sait qu'il doit transformer le dictionnaire reçu en instance de Client, en le validant au passage. On obtient un véritable objet, accessible par commande.client.nom, et non un simple dictionnaire. Une erreur dans le client imbriqué remonte d'ailleurs avec un loc du type ('client', 'email'), qui pointe précisément le champ fautif en profondeur.

PositiveInt est un raccourci pour « entier strictement supérieur à 0 ». Pydantic fournit plusieurs types de ce genre, qui combinent un type de base et une contrainte. C'est plus lisible que de répéter la contrainte à la main.

Field sert à attacher des contraintes à un champ. ge et le (greater/less than or equal) bornent une valeur numérique. Field accepte beaucoup d'autres options : longueur de chaîne (min_length, max_length), motif d'expression régulière, description pour la documentation. C'est l'outil pour préciser une règle que le type seul n'exprime pas.

list[str] valide que articles est bien une liste et que chacun de ses éléments est une chaîne. La validation est récursive : Pydantic descend dans les structures composées plutôt que de vérifier seulement le type de premier niveau.


Étape 4 – Charger et valider du JSON

Sujet

En pratique, les données arrivent souvent en JSON (fichier de configuration, réponse d'API). Pydantic sait valider directement une chaîne JSON sans qu'on ait à la convertir d'abord en dictionnaire.

L'objectif est de valider une chaîne JSON avec model_validate_json() et de gérer le cas d'un JSON mal formé ou non conforme.

Énoncé.

  1. Reprends le modèle Utilisateur de l'étape 1.
  2. Prépare une chaîne JSON valide représentant un utilisateur.
  3. Valide-la avec la méthode de classe Utilisateur.model_validate_json(chaine). Elle renvoie une instance validée.
  4. Fais l'opération inverse : sérialise une instance en JSON avec .model_dump_json().
  5. Teste avec un JSON conforme à la syntaxe mais invalide pour le modèle (type incorrect) et observe que c'est une ValidationError qui est levée, pas une erreur de parsing JSON.

Solution

from pydantic import BaseModel, ValidationError


class Utilisateur(BaseModel):
    id: int
    nom: str = "Anonyme"
    actif: bool = True


# JSON -> modèle
json_valide = '{"id": 42, "nom": "Paul", "actif": false}'
utilisateur = Utilisateur.model_validate_json(json_valide)
print(utilisateur)
# id=42 nom='Paul' actif=False

# modèle -> JSON
print(utilisateur.model_dump_json())
# {"id":42,"nom":"Paul","actif":false}

# JSON syntaxiquement correct mais invalide pour le modèle
json_invalide = '{"id": "abc", "nom": "Paul"}'
try:
    Utilisateur.model_validate_json(json_invalide)
except ValidationError as erreur:
    print(erreur.errors()[0]["msg"])
    # Input should be a valid integer, unable to parse string as an integer

Explication

model_validate_json() combine deux étapes en une : il analyse la chaîne JSON et valide le résultat contre le modèle. C'est plus efficace que de faire json.loads() puis Utilisateur(**donnees), car Pydantic 2 effectue le décodage dans son cœur écrit en Rust, sans construire de dictionnaire intermédiaire en Python.

Il faut distinguer deux types d'échec possibles. Si la chaîne n'est pas du JSON valide (accolade manquante, virgule en trop), l'erreur concerne le décodage. Si la chaîne est du JSON correct mais ne respecte pas le modèle (un entier attendu reçoit du texte), c'est une ValidationError. Dans l'exemple, le JSON est bien formé, donc c'est la validation qui échoue.

model_dump_json() est le pendant pour la sortie : il produit directement une chaîne JSON à partir du modèle, en convertissant correctement les types Python qui n'existent pas tels quels en JSON, comme les dates. C'est ce qu'on utilise pour renvoyer des données validées vers l'extérieur, par exemple dans une réponse d'API.

Ce couple entrée/sortie résume l'usage typique de Pydantic : valider les données qui entrent dans le programme, et sérialiser proprement celles qui en sortent, avec le modèle comme contrat unique au milieu.


Pour aller plus loin

  • Découvrir les validateurs personnalisés avec le décorateur @field_validator, pour des règles que les contraintes de base ne couvrent pas (format d'email réel, valeur dépendant d'un autre champ).
  • Utiliser le type EmailStr (nécessite pip install pydantic[email]) pour valider de vraies adresses email.
  • Explorer le mode strict (strict=True), où Pydantic refuse les conversions et exige le type exact.
  • Comparer un modèle Pydantic avec une dataclass standard pour mesurer ce que la validation apporte.
  • Voir comment FastAPI s'appuie sur Pydantic pour valider automatiquement les requêtes HTTP.

TP : Sécuriser une API avec FastAPI

Prérequis

  • Python 3.10 ou plus récent
  • Connaissances : fonctions, décorateurs, notions de HTTP (méthodes GET/POST, codes de statut)
  • Un environnement virtuel actif (voir le TP sur les venv)
  • Avoir vu Pydantic est un plus (FastAPI s'appuie dessus)

De quoi on parle

Une API ouverte sur le réseau est exposée : n'importe qui peut l'appeler. Sécuriser une API, c'est répondre à plusieurs questions distinctes. Qui appelle (authentification) ? A-t-il le droit de faire ça (autorisation) ? N'abuse-t-il pas du service (limitation de débit) ? Les données reçues sont-elles saines (validation) ?

Ce TP traite trois briques concrètes : la clé d'API comme premier filtre simple, le jeton JWT pour gérer des utilisateurs authentifiés, et la limitation de débit pour se protéger des abus.

Installation

pip install "fastapi[standard]" "python-jose[cryptography]" "passlib[bcrypt]" slowapi

Lancer l'API

Chaque étape se lance avec :

fastapi dev main.py

L'interface de test interactive est alors disponible sur http://127.0.0.1:8000/docs.


Étape 1 – Protéger une route par clé d'API

Sujet

Le filtre le plus simple : exiger une clé secrète dans l'en-tête de chaque requête. Sans la bonne clé, l'API refuse de répondre. C'est adapté aux communications entre services (un programme qui appelle un autre).

L'objectif est de créer une route protégée qui vérifie la présence et la validité d'une clé transmise dans l'en-tête HTTP X-API-Key.

Énoncé.

  1. Crée une application FastAPI dans main.py.
  2. Définis une clé attendue. Pour le TP, une constante suffit ; en production elle viendrait d'une variable d'environnement (jamais en dur dans le code).
  3. Utilise APIKeyHeader depuis fastapi.security pour déclarer que la clé est lue dans l'en-tête X-API-Key.
  4. Écris une dépendance verifier_cle qui compare la clé reçue à la clé attendue. Si elle ne correspond pas, lève une HTTPException avec le code 401 (non autorisé).
  5. Applique cette dépendance à une route /donnees via le paramètre dependencies du décorateur.
  6. Teste depuis /docs : sans clé, la route renvoie 401 ; avec la bonne clé, elle répond.

Solution

import os
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import APIKeyHeader

app = FastAPI()

# En production : os.environ["API_KEY"]. Ici une valeur de repli pour le TP.
CLE_ATTENDUE = os.getenv("API_KEY", "ma-cle-secrete-de-test")

en_tete_cle = APIKeyHeader(name="X-API-Key")


def verifier_cle(cle: str = Depends(en_tete_cle)):
    if cle != CLE_ATTENDUE:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Clé d'API invalide",
        )


@app.get("/donnees", dependencies=[Depends(verifier_cle)])
def lire_donnees():
    return {"message": "Accès autorisé", "donnees": [1, 2, 3]}

Test en ligne de commande :

# Sans clé -> 401
curl http://127.0.0.1:8000/donnees

# Avec la bonne clé -> 200
curl -H "X-API-Key: ma-cle-secrete-de-test" http://127.0.0.1:8000/donnees

Explication

Une dépendance FastAPI est une fonction exécutée avant la route, dont le résultat (ou l'effet) conditionne l'accès. APIKeyHeader(name="X-API-Key") indique à FastAPI d'extraire la valeur de cet en-tête et de la fournir à verifier_cle. Si l'en-tête est absent, FastAPI renvoie déjà une erreur avant même d'entrer dans la fonction.

La vérification elle-même est une simple comparaison. Si elle échoue, on lève une HTTPException avec le code 401, qui signifie « tu n'es pas authentifié ». FastAPI transforme cette exception en réponse HTTP propre, sans planter le serveur. Le code 401 est le bon choix sémantique, à distinguer du 403 (« tu es authentifié mais tu n'as pas le droit »).

Le paramètre dependencies=[Depends(verifier_cle)] attache la vérification à la route sans que la fonction lire_donnees ait besoin de recevoir la clé en argument : ici on veut juste contrôler l'accès, pas utiliser la valeur. C'est le bon réflexe quand une dépendance sert de garde-barrière.

Point important : la clé ne doit jamais être écrite en dur dans le code source versionné. On la lit depuis une variable d'environnement. La valeur de repli n'est là que pour faire tourner le TP rapidement.


Étape 2 – Authentifier un utilisateur et émettre un JWT

Sujet

La clé d'API ne distingue pas les utilisateurs entre eux. Pour gérer des comptes, on utilise un jeton JWT (JSON Web Token) : l'utilisateur s'authentifie une fois avec ses identifiants, reçoit un jeton signé, et le présente à chaque requête suivante.

L'objectif est de créer une route de connexion qui vérifie un mot de passe et renvoie un JWT signé contenant l'identité de l'utilisateur.

Énoncé.

  1. Pour signer les jetons, définis une clé secrète et un algorithme (HS256). La clé secrète sert à signer et vérifier ; elle ne doit jamais sortir du serveur.
  2. Stocke les mots de passe sous forme hachée, jamais en clair. Utilise passlib avec bcrypt via un CryptContext. Prépare un faux utilisateur avec un mot de passe haché.
  3. Crée une route POST /login qui reçoit un identifiant et un mot de passe.
  4. Vérifie le mot de passe avec pwd_context.verify(). S'il est faux, renvoie 401.
  5. S'il est correct, génère un JWT avec jose.jwt.encode(). Le contenu (payload) doit inclure le sub (l'identifiant de l'utilisateur) et un exp (date d'expiration) pour que le jeton ne soit pas valable indéfiniment.
  6. Renvoie le jeton.

Solution

from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from passlib.context import CryptContext
from jose import jwt

app = FastAPI()

SECRET = "secret-de-signature-a-garder-cote-serveur"
ALGORITHME = "HS256"
DUREE_JETON_MIN = 30

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# Faux annuaire : mot de passe "motdepasse" pré-haché.
UTILISATEURS = {
    "paul": pwd_context.hash("motdepasse"),
}


class Identifiants(BaseModel):
    nom: str
    mot_de_passe: str


def creer_jeton(nom: str) -> str:
    expiration = datetime.now(timezone.utc) + timedelta(minutes=DUREE_JETON_MIN)
    payload = {"sub": nom, "exp": expiration}
    return jwt.encode(payload, SECRET, algorithm=ALGORITHME)


@app.post("/login")
def login(identifiants: Identifiants):
    hache = UTILISATEURS.get(identifiants.nom)
    if hache is None or not pwd_context.verify(identifiants.mot_de_passe, hache):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Identifiant ou mot de passe invalide",
        )
    jeton = creer_jeton(identifiants.nom)
    return {"access_token": jeton, "token_type": "bearer"}

Test :

curl -X POST http://127.0.0.1:8000/login \
  -H "Content-Type: application/json" \
  -d '{"nom": "paul", "mot_de_passe": "motdepasse"}'
# {"access_token": "eyJhbGc...", "token_type": "bearer"}

Explication

Les mots de passe ne sont jamais stockés en clair. bcrypt les transforme en empreinte irréversible : on ne peut pas retrouver le mot de passe à partir du haché. À la connexion, verify() rehache le mot de passe fourni et compare les empreintes. Si la base de données fuite, les mots de passe restent protégés. Le message d'erreur reste volontairement vague (« identifiant ou mot de passe invalide ») pour ne pas révéler lequel des deux est faux à un attaquant.

Un JWT contient trois parties : un en-tête, des données (le payload), et une signature. La signature est calculée avec la clé secrète du serveur. N'importe qui peut lire le contenu d'un JWT (il n'est pas chiffré, juste encodé), mais personne ne peut le modifier sans connaître la clé secrète : toute altération invalide la signature. C'est ce qui rend le jeton fiable.

Le champ sub (subject) identifie l'utilisateur. Le champ exp fixe une expiration : passé ce délai, le jeton est rejeté. Sans expiration, un jeton volé resterait utilisable pour toujours. La durée est un compromis entre confort (ne pas se reconnecter sans cesse) et sécurité (limiter la fenêtre d'un vol).

La réponse renvoie le jeton avec token_type: bearer, qui est la convention : le client le présentera ensuite dans un en-tête Authorization: Bearer <jeton>, ce qu'on traite à l'étape suivante.


Étape 3 – Protéger une route avec le JWT

Sujet

Le jeton émis à l'étape 2 doit maintenant servir à accéder aux routes protégées. Le client l'envoie dans l'en-tête Authorization, et l'API le vérifie : signature valide, non expiré, et en extrait l'identité.

L'objectif est d'écrire une dépendance qui valide le JWT et fournit l'utilisateur courant aux routes protégées.

Énoncé.

  1. Utilise OAuth2PasswordBearer depuis fastapi.security, configuré avec l'URL de connexion (tokenUrl="login"). Il extrait automatiquement le jeton de l'en-tête Authorization: Bearer ....
  2. Écris une dépendance utilisateur_courant qui décode le jeton avec jwt.decode(), en passant la clé secrète et l'algorithme.
  3. Si le décodage échoue (signature invalide, jeton expiré), jose lève une JWTError : intercepte-la et renvoie un 401.
  4. Récupère l'identifiant dans le champ sub du payload décodé et renvoie-le.
  5. Crée une route protégée /profil qui reçoit l'utilisateur courant en dépendance et renvoie son nom.

Solution

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError

# (réutilise SECRET et ALGORITHME de l'étape 2)
SECRET = "secret-de-signature-a-garder-cote-serveur"
ALGORITHME = "HS256"

app = FastAPI()

oauth2 = OAuth2PasswordBearer(tokenUrl="login")


def utilisateur_courant(jeton: str = Depends(oauth2)) -> str:
    erreur_401 = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Jeton invalide ou expiré",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(jeton, SECRET, algorithms=[ALGORITHME])
    except JWTError:
        raise erreur_401
    nom = payload.get("sub")
    if nom is None:
        raise erreur_401
    return nom


@app.get("/profil")
def profil(nom: str = Depends(utilisateur_courant)):
    return {"utilisateur": nom, "message": "Tu es bien authentifié"}

Test :

JETON="<colle ici le jeton obtenu via /login>"
curl http://127.0.0.1:8000/profil -H "Authorization: Bearer $JETON"
# {"utilisateur": "paul", "message": "Tu es bien authentifié"}

Explication

OAuth2PasswordBearer automatise l'extraction du jeton : il lit l'en-tête Authorization, vérifie qu'il commence par Bearer , et passe la partie jeton à la dépendance. Si l'en-tête manque, il renvoie un 401 sans qu'on ait à le coder. Le paramètre tokenUrl sert surtout à la documentation /docs, qui affiche alors un bouton de connexion intégré.

jwt.decode() fait trois choses d'un coup : il vérifie la signature avec la clé secrète, il contrôle que le jeton n'a pas expiré (champ exp), et il renvoie le payload décodé. N'importe lequel de ces contrôles qui échoue déclenche une JWTError. C'est pour ça qu'un seul try / except suffit à couvrir « signature falsifiée », « jeton expiré » et « jeton illisible ».

Comme la dépendance renvoie le nom de l'utilisateur, n'importe quelle route peut le recevoir simplement en déclarant nom: str = Depends(utilisateur_courant). La logique de vérification est écrite une fois et réutilisée partout. C'est la force du système de dépendances : on protège dix routes avec la même dépendance, sans dupliquer le code.

L'en-tête WWW-Authenticate: Bearer dans la réponse 401 est la convention HTTP qui indique au client quel type d'authentification est attendu. C'est un détail correct à inclure, même si beaucoup de clients l'ignorent.


Étape 4 – Limiter le débit des requêtes

Sujet

Même authentifiée, une API peut être malmenée : un client qui envoie des milliers de requêtes par seconde, par erreur ou par malveillance (attaque par force brute sur le login, déni de service). La limitation de débit (rate limiting) plafonne le nombre de requêtes autorisées par client sur une période.

L'objectif est d'ajouter une limite de débit à l'API avec la bibliothèque slowapi, et de l'appliquer en priorité à la route de connexion.

Énoncé.

  1. Crée un Limiter de slowapi, en utilisant get_remote_address comme fonction d'identification du client (basée sur l'adresse IP).
  2. Attache le limiteur à l'application : stocke-le dans app.state.limiter et enregistre le gestionnaire d'erreur _rate_limit_exceeded_handler pour l'exception RateLimitExceeded.
  3. Applique une limite à une route avec le décorateur @limiter.limit("5/minute"). La fonction de route doit alors recevoir le paramètre request: Request (slowapi en a besoin pour identifier le client).
  4. Mets une limite stricte sur /login (par exemple 5/minute) pour freiner les tentatives de mot de passe.
  5. Teste en appelant la route plus de 5 fois en une minute : la sixième requête doit renvoyer le code 429 (trop de requêtes).

Solution

from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

app = FastAPI()

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)


@app.get("/public")
@limiter.limit("5/minute")
def route_publique(request: Request):
    return {"message": "OK"}

Appliqué au login de l'étape 2 :

@app.post("/login")
@limiter.limit("5/minute")
def login(request: Request, identifiants: Identifiants):
    # ... même logique qu'à l'étape 2 ...
    ...

Test rapide :

for i in $(seq 1 6); do
  curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/public
done
# 200 200 200 200 200 429  -> la 6e requête est bloquée

Explication

Le rôle de key_func est de répondre à la question « qui est ce client ? ». get_remote_address utilise l'adresse IP, ce qui est le choix par défaut raisonnable. On pourrait identifier le client autrement, par exemple par son utilisateur authentifié, pour appliquer des quotas par compte plutôt que par IP.

@limiter.limit("5/minute") plafonne à cinq requêtes par minute et par client. Au-delà, slowapi rejette la requête avec le code 429 (Too Many Requests), sans exécuter le corps de la route. La fenêtre se réinitialise progressivement, ce qui laisse repasser les requêtes une fois le rythme retombé. L'ordre des décorateurs compte : @limiter.limit se place sous @app.get.

L'obligation de déclarer request: Request vient du fonctionnement de slowapi : il a besoin de l'objet requête pour lire l'adresse IP via key_func. C'est une contrainte technique à ne pas oublier, sinon la limite ne s'applique pas.

Mettre une limite serrée sur /login est le cas d'usage le plus important. Sans elle, un attaquant peut tester des milliers de mots de passe à la suite (force brute). Cinq tentatives par minute rendent cette attaque impraticable tout en ne gênant pas un utilisateur normal qui se trompe une ou deux fois.

À noter pour la production : slowapi stocke par défaut les compteurs en mémoire, ce qui ne tient pas si l'API tourne sur plusieurs processus ou serveurs. On le configure alors avec un stockage partagé comme Redis pour que la limite soit cohérente sur l'ensemble.


Pour aller plus loin

  • Ajouter une notion d'autorisation par rôles : inclure un champ role dans le JWT et créer une dépendance qui exige un rôle précis (renvoyer 403 sinon).
  • Gérer le rafraîchissement des jetons (refresh token) pour éviter de redemander le mot de passe à chaque expiration.
  • Charger les secrets depuis un fichier .env avec pydantic-settings plutôt que des constantes.
  • Ajouter des en-têtes de sécurité (CORS restreint, en-têtes HSTS) via un middleware.
  • Mesurer l'effet du rate limiting sur /login en simulant une attaque par force brute.
Ce TP s'exécute en HTTP local pour simplifier. En production, une API qui transporte des mots de passe et des jetons doit impérativement être servie en HTTPS, sans quoi ces secrets circulent en clair sur le réseau.

TP – Gérer un projet Python avec uv

Prérequis

  • Un terminal (PowerShell, bash ou zsh)
  • Connaissances de base en ligne de commande
  • Aucune installation Python préalable nécessaire : uv sait installer Python lui-même

Ce qu'est uv

uv est un outil unique qui remplace plusieurs briques de l'écosystème Python : pip pour installer des paquets, venv/virtualenv pour les environnements, pyenv pour gérer les versions de Python, pip-tools pour figer les dépendances, et pipx pour les outils en ligne de commande. Il est écrit en Rust, ce qui le rend nettement plus rapide que ces outils pris séparément.

Le changement de mentalité par rapport à pip + venv : avec uv, on n'active plus manuellement d'environnement. On décrit le projet dans un fichier pyproject.toml, et uv crée et synchronise l'environnement tout seul à chaque commande.

Installation de uv

# Linux / macOS
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Vérification

uv --version

Étape 1 – Créer un projet et lancer du code

Sujet

On crée un projet de zéro avec uv. La commande génère une structure complète : fichier de configuration, version de Python épinglée, dépôt Git initialisé, et un script d'exemple. On exécute ensuite ce script sans jamais activer d'environnement à la main.

L'objectif est d'initialiser un projet avec uv init et d'exécuter son code avec uv run.

Énoncé.

  1. Crée un projet nommé mon-projet avec la commande uv init mon-projet, puis place-toi dans le dossier créé.
  2. Liste le contenu du dossier et repère les fichiers générés : pyproject.toml, .python-version, main.py, README.md, .gitignore.
  3. Ouvre pyproject.toml pour voir la structure : un bloc [project] avec le nom, la version, et une liste dependencies vide.
  4. Exécute le script d'exemple avec uv run main.py. Observe que cette première exécution crée automatiquement l'environnement virtuel .venv et le fichier uv.lock.
  5. Relance la même commande et constate qu'elle est instantanée : l'environnement existe déjà.

Solution

uv init mon-projet
cd mon-projet

ls -a
# .git  .gitignore  .python-version  main.py  pyproject.toml  README.md

cat pyproject.toml
# [project]
# name = "mon-projet"
# version = "0.1.0"
# description = "Add your description here"
# readme = "README.md"
# requires-python = ">=3.x"
# dependencies = []

uv run main.py
# Using CPython 3.x
# Creating virtual environment at: .venv
# Hello from mon-projet!

Explication

uv init ne se contente pas de créer un dossier : il pose toute la structure d'un projet Python moderne conforme aux standards. Le pyproject.toml est le fichier central, qui remplace l'ancien duo setup.py + requirements.txt. Le fichier .python-version enregistre la version de Python du projet, et le dépôt Git est initialisé avec un .gitignore déjà adapté.

La particularité de uv apparaît à la première commande uv run : l'environnement virtuel .venv et le verrou uv.lock n'existent pas avant, ils sont créés à ce moment-là. On n'a pas eu à taper python -m venv ni à activer quoi que ce soit. uv détecte qu'il manque un environnement, le construit, puis exécute le script dedans.

uv run fait une vérification avant chaque exécution : il s'assure que l'environnement correspond bien à ce qui est déclaré dans le pyproject.toml et le uv.lock. Si une dépendance manque ou si le fichier de configuration a changé, uv met l'environnement à jour avant de lancer la commande. C'est ce qui garantit qu'on travaille toujours dans un environnement cohérent, sans manipulation manuelle.


Étape 2 – Ajouter et retirer des dépendances

Sujet

On ajoute des paquets au projet. Contrairement à pip install, la commande uv add met aussi à jour le pyproject.toml et le fichier de verrou, gardant la déclaration du projet synchronisée avec ce qui est réellement installé.

L'objectif est d'ajouter des dépendances avec uv add, d'en retirer avec uv remove, et de comprendre le rôle du uv.lock.

Énoncé.

  1. Ajoute le paquet requests avec uv add requests. Observe qu'uv résout, télécharge, installe, et met à jour les fichiers du projet en une seule commande.
  2. Ouvre pyproject.toml : requests apparaît maintenant dans la liste dependencies.
  3. Ajoute deux paquets d'un coup : uv add rich httpx.
  4. Ajoute une dépendance de développement (utile pour le projet mais pas pour son fonctionnement en production) avec uv add --dev pytest.
  5. Vérifie l'arbre des dépendances avec uv tree.
  6. Retire un paquet avec uv remove httpx et constate qu'il disparaît du pyproject.toml.

Solution

uv add requests
# Resolved N packages
# + requests==2.x.x  (et ses dépendances)

cat pyproject.toml
# dependencies = [
#     "requests>=2.x",
# ]

uv add rich httpx
uv add --dev pytest

uv tree
# mon-projet v0.1.0
# ├── requests v2.x
# │   ├── certifi v...
# │   └── urllib3 v...
# ├── rich v...
# └── ...

uv remove httpx

Explication

uv add fait en une commande ce qui demandait plusieurs étapes avec pip : installer le paquet, l'inscrire dans la liste des dépendances, et figer sa version exacte. Avec pip, installer un paquet ne touchait pas le requirements.txt ; il fallait penser à le mettre à jour soi-même, source classique d'oublis. uv supprime ce risque en synchronisant tout automatiquement.

Le uv.lock joue le rôle de verrou de reproductibilité. Là où le pyproject.toml exprime des contraintes larges (« requests version 2 ou plus »), le uv.lock enregistre les versions exactes résolues, y compris celles des dépendances indirectes. Cet écart est volontaire : le pyproject.toml reste lisible et souple, tandis que le verrou garantit que deux installations donneront exactement le même résultat. À la différence d'un pip freeze, le uv.lock est multi-plateforme par défaut.

La distinction --dev sépare les outils de développement (tests, formatage) des dépendances réellement nécessaires au fonctionnement. Ça permet plus tard d'installer un environnement de production sans ces outils, donc plus léger. C'est une séparation que pip et requirements.txt ne gèrent pas nativement.

uv tree affiche la hiérarchie complète : on voit non seulement les paquets demandés mais aussi ce qu'ils ont entraîné. Utile pour comprendre pourquoi un paquet inattendu est présent.


Étape 3 – Reconstruire un environnement à l'identique

Sujet

On simule l'arrivée sur un projet existant, ou un déploiement sur une autre machine : on a le code, le pyproject.toml et le uv.lock, mais pas l'environnement. uv le reconstruit exactement à partir du verrou.

L'objectif est de reconstruire un environnement avec uv sync et de comprendre pourquoi le dossier .venv n'a pas besoin d'être conservé.

Énoncé.

  1. Supprime le dossier d'environnement .venv. Sous Linux/macOS : rm -rf .venv. Sous Windows : Remove-Item -Recurse -Force .venv. C'est sans risque : il est entièrement reconstructible.
  2. Reconstruis l'environnement avec uv sync. La commande lit le uv.lock et installe précisément les versions qui y sont figées.
  3. Vérifie que tout est revenu en lançant le code : uv run main.py.
  4. Pour un environnement de production, teste la variante stricte : uv sync --frozen --no-dev. --frozen interdit toute modification du verrou, --no-dev exclut les dépendances de développement.
  5. Confirme que .venv et uv.lock figurent bien dans le .gitignore (uv y ajoute .venv automatiquement).

Solution

# Supprimer l'environnement (Linux / macOS)
rm -rf .venv

# Reconstruire à partir du verrou
uv sync
# Resolved N packages
# Installed N packages

# Vérifier
uv run main.py

# Variante production : verrou figé, sans outils de dev
uv sync --frozen --no-dev

# Contrôler le .gitignore
cat .gitignore
# .venv  (et autres entrées)

Explication

uv sync est la commande qui rend un projet portable. Elle lit le uv.lock et installe exactement les versions verrouillées, ni plus récentes ni plus anciennes. C'est ce qui garantit qu'un collègue qui clone le dépôt, ou un serveur d'intégration continue, obtient le même environnement au paquet près. Tant que uv sync reconstruit correctement à partir du seul code versionné, le projet est sain.

Supprimer .venv est anodin pour la même raison qu'avec un venv classique : ce dossier ne contient que des paquets installés, jamais de code unique du projet. Il est lourd, propre à chaque système d'exploitation, et reconstructible à volonté. On l'exclut donc de Git. Ce qu'on versionne, c'est le pyproject.toml (les intentions) et le uv.lock (les versions exactes).

Les options de production méritent attention. --frozen empêche uv de recalculer ou modifier le verrou : si le pyproject.toml et le uv.lock ne sont pas cohérents, la commande échoue plutôt que d'installer silencieusement quelque chose de différent. C'est exactement ce qu'on veut en déploiement : un comportement prévisible, qui refuse les surprises. --no-dev écarte les outils de test et de formatage, inutiles en production, pour un environnement plus léger.


Étape 4 – Gérer les versions de Python et les outils

Sujet

uv remplace aussi pyenv (gestion des versions de Python) et pipx (outils en ligne de commande globaux). On peut installer plusieurs versions de Python, épingler celle d'un projet, et exécuter des outils comme ruff ou black sans les installer durablement.

L'objectif est d'installer une version de Python, de l'épingler au projet, et d'utiliser uvx pour lancer un outil sans l'installer.

Énoncé.

  1. Liste les versions de Python disponibles et installées avec uv python list.
  2. Installe une version précise, par exemple uv python install 3.12.
  3. Épingle cette version au projet courant avec uv python pin 3.12. La commande écrit la version dans .python-version.
  4. Relance uv run main.py : uv utilise désormais la version épinglée pour l'environnement.
  5. Exécute un outil sans l'installer durablement avec uvx (alias de uv tool run) : par exemple uvx ruff check . pour analyser le code du projet.
  6. Pour un outil dont tu te sers souvent, installe-le globalement : uv tool install ruff, puis appelle simplement ruff --version.

Solution

# Versions disponibles et installées
uv python list

# Installer une version
uv python install 3.12

# Épingler au projet
uv python pin 3.12
cat .python-version
# 3.12

uv run main.py

# Lancer un outil sans l'installer (environnement jetable)
uvx ruff check .

# Installer un outil globalement, disponible dans le PATH
uv tool install ruff
ruff --version

Explication

uv télécharge et gère ses propres versions de Python, sans dépendre de ce qui est installé sur le système. uv python install 3.12 récupère un interpréteur officiel et le stocke dans un emplacement géré par uv. On peut avoir plusieurs versions côte à côte et passer de l'une à l'autre, ce qui remplace entièrement pyenv.

uv python pin écrit la version dans le fichier .python-version, que uv lit ensuite à chaque commande de projet. Comme ce fichier est versionné, toute l'équipe travaille automatiquement sur la même version de Python, sans configuration manuelle. C'est un gain de cohérence important sur les projets à plusieurs.

uvx est le pendant de npx du monde JavaScript : il exécute un outil dans un environnement temporaire et jetable, créé pour l'occasion et oublié ensuite. C'est idéal pour un usage ponctuel — lancer ruff une fois sans polluer l'environnement du projet ni le système. Rien n'est installé durablement.

uv tool install est différent : il installe l'outil dans son propre environnement isolé, mais rend sa commande disponible en permanence dans le PATH. C'est le bon choix pour les outils qu'on utilise tous les jours, et le remplaçant direct de pipx. La distinction est la même que tout à l'heure entre l'usage jetable et l'usage durable.


Pour aller plus loin

  • Comparer le temps d'installation d'un même ensemble de paquets entre pip et uv sur un cache vide, pour mesurer l'écart de vitesse.
  • Migrer un projet existant : partir d'un requirements.txt, le réimporter avec uv add -r requirements.txt, puis basculer vers le pyproject.toml.
  • Découvrir les dépendances en ligne pour un script unique avec uv add --script script.py requests (métadonnées PEP 723, sans projet complet).
  • Mettre en place un workflow d'intégration continue avec uv sync --frozen pour des installations reproductibles et rapides.
  • Explorer les workspaces pour gérer plusieurs projets liés (microservices) partageant un même verrou.

TP – Manipulations avancées d'Excel avec openpyxl

Prérequis

  • Python 3.10 ou plus récent
  • Connaissances : classes, boucles, compréhensions de liste, gestion de fichiers
  • Un environnement virtuel actif (voir le TP sur les venv ou uv)
  • Notions d'Excel : cellules, feuilles, formules, coordonnées (A1, B2...)

Ce que fait openpyxl

openpyxl lit et écrit des fichiers Excel .xlsx sans avoir Excel installé. Contrairement à pandas, qui voit un fichier Excel comme un simple tableau de données, openpyxl donne accès à la structure fine du classeur : le style de chaque cellule, les formules, les graphiques, les mises en forme conditionnelles, le figeage des volets. C'est l'outil quand le rendu du fichier compte autant que les données.

Installation

pip install openpyxl
# ou avec uv : uv add openpyxl

Vérification

python -c "import openpyxl; print(openpyxl.__version__)"

Étape 1 – Créer un classeur mis en forme

Sujet

On construit un classeur de zéro et on applique une mise en forme soignée : en-têtes en gras sur fond coloré, bordures, alignement, largeurs de colonnes ajustées. C'est la base de tout rapport Excel présentable généré par programme.

L'objectif est de créer un tableau de ventes avec un en-tête stylé et des données correctement formatées.

Énoncé.

  1. Importe Workbook depuis openpyxl et les classes de style : Font, PatternFill, Alignment, Border, Side depuis openpyxl.styles.
  2. Crée un classeur avec Workbook() et récupère la feuille active. Renomme-la "Ventes" via l'attribut .title.
  3. Écris une ligne d'en-tête sur la première ligne : Produit, Quantité, Prix unitaire, Total. On écrit dans une cellule avec feuille["A1"] = "valeur" ou feuille.cell(row=1, column=1, value="valeur").
  4. Applique aux cellules d'en-tête : une police blanche en gras (Font(bold=True, color="FFFFFF")), un fond bleu (PatternFill(start_color="2563EB", fill_type="solid")), et un alignement centré.
  5. Ajoute quelques lignes de données produits sous l'en-tête.
  6. Ajuste la largeur des colonnes via feuille.column_dimensions["A"].width = 20.
  7. Sauvegarde avec classeur.save("ventes.xlsx").

Solution

from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side

classeur = Workbook()
feuille = classeur.active
feuille.title = "Ventes"

# En-tête
entetes = ["Produit", "Quantité", "Prix unitaire", "Total"]
feuille.append(entetes)

# Style de l'en-tête
police_entete = Font(bold=True, color="FFFFFF")
fond_entete = PatternFill(start_color="2563EB", end_color="2563EB", fill_type="solid")
centre = Alignment(horizontal="center", vertical="center")
bordure = Border(
    left=Side(style="thin"), right=Side(style="thin"),
    top=Side(style="thin"), bottom=Side(style="thin"),
)

for cellule in feuille[1]:            # feuille[1] = toutes les cellules de la ligne 1
    cellule.font = police_entete
    cellule.fill = fond_entete
    cellule.alignment = centre
    cellule.border = bordure

# Données
donnees = [
    ["Clavier", 12, 45.00],
    ["Souris", 30, 19.90],
    ["Écran 27\"", 8, 259.00],
]
for ligne in donnees:
    feuille.append(ligne)

# Largeurs de colonnes
largeurs = {"A": 20, "B": 12, "C": 15, "D": 15}
for colonne, largeur in largeurs.items():
    feuille.column_dimensions[colonne].width = largeur

classeur.save("ventes.xlsx")
print("Fichier créé : ventes.xlsx")

Explication

Un Workbook neuf contient déjà une feuille, récupérée par .active. append() est la façon la plus simple d'ajouter une ligne : on lui passe une liste, et chaque élément va dans une colonne successive. C'est plus lisible que d'adresser chaque cellule une par une quand on remplit ligne par ligne.

Les styles d'openpyxl sont des objets qu'on assigne aux attributs de la cellule (.font, .fill, .alignment, .border). Un point important : ces objets sont immuables. On ne modifie pas un style existant, on en crée un nouveau et on le réaffecte. C'est pour ça qu'on définit police_entete une fois et qu'on l'applique à plusieurs cellules dans la boucle.

feuille[1] désigne l'ensemble des cellules de la ligne 1. Parcourir cette ligne pour styliser chaque cellule est nécessaire car un style s'applique cellule par cellule, pas à une ligne entière d'un coup. Les couleurs sont en hexadécimal ; on peut préfixer par FF pour l'opacité, mais openpyxl le complète si on l'omet.

Les largeurs de colonnes se règlent via column_dimensions, indexé par la lettre de colonne. L'unité correspond approximativement au nombre de caractères affichables. Sans ce réglage, les colonnes gardent la largeur par défaut et le texte long est tronqué à l'affichage.


Étape 2 – Formules, mise en forme conditionnelle et figeage

Sujet

On enrichit le tableau avec des formules Excel calculées par le tableur (et non par Python), une mise en forme conditionnelle qui colore les cellules selon leur valeur, et le figeage de l'en-tête pour qu'il reste visible au défilement.

L'objectif est d'ajouter une colonne de totaux calculée par formule, une ligne de somme, et une règle de couleur sur les quantités.

Énoncé.

  1. Reprends le classeur de l'étape 1 (ou recharge ventes.xlsx avec load_workbook).
  2. Dans la colonne Total (D), écris pour chaque ligne de données une formule Excel qui multiplie la quantité par le prix : la chaîne "=B2*C2" dans la cellule D2, et ainsi de suite. openpyxl écrit la formule telle quelle ; c'est Excel qui la calculera à l'ouverture.
  3. Ajoute une ligne de total sous les données, avec une formule =SUM(D2:D4) (adapte les bornes au nombre de lignes).
  4. Applique une mise en forme conditionnelle sur la colonne des quantités (B) : les valeurs supérieures à 20 en vert, avec CellIsRule depuis openpyxl.formatting.rule.
  5. Fige la première ligne pour qu'elle reste affichée au défilement avec feuille.freeze_panes = "A2".
  6. Sauvegarde sous un nouveau nom.

Solution

from openpyxl import load_workbook
from openpyxl.styles import Font, PatternFill
from openpyxl.formatting.rule import CellIsRule

classeur = load_workbook("ventes.xlsx")
feuille = classeur["Ventes"]

# Nombre de lignes de données (hors en-tête)
derniere = feuille.max_row   # ici 4 : en-tête + 3 produits

# Formule Total par ligne
for ligne in range(2, derniere + 1):
    feuille[f"D{ligne}"] = f"=B{ligne}*C{ligne}"

# Ligne de total
ligne_total = derniere + 1
feuille[f"A{ligne_total}"] = "TOTAL"
feuille[f"A{ligne_total}"].font = Font(bold=True)
feuille[f"D{ligne_total}"] = f"=SUM(D2:D{derniere})"
feuille[f"D{ligne_total}"].font = Font(bold=True)

# Mise en forme conditionnelle : quantité > 20 en vert
vert = PatternFill(start_color="BBF7D0", end_color="BBF7D0", fill_type="solid")
regle = CellIsRule(operator="greaterThan", formula=["20"], fill=vert)
feuille.conditional_formatting.add(f"B2:B{derniere}", regle)

# Figer l'en-tête
feuille.freeze_panes = "A2"

classeur.save("ventes_formules.xlsx")
print("Fichier créé : ventes_formules.xlsx")

Explication

Une distinction fondamentale : openpyxl écrit les formules comme du texte, il ne les calcule pas. Quand on met "=B2*C2" dans une cellule, openpyxl enregistre cette chaîne, et c'est Excel (ou LibreOffice) qui effectue le calcul en ouvrant le fichier. Conséquence : si on relit ce fichier avec openpyxl juste après l'avoir écrit, la cellule contient la formule, pas le résultat. Pour lire les résultats calculés, il faut ouvrir avec load_workbook(..., data_only=True), mais uniquement après que le fichier a été ouvert et sauvegardé par un vrai tableur.

max_row donne le numéro de la dernière ligne remplie, ce qui évite de coder en dur le nombre de produits. On construit les références de cellules dynamiquement avec des f-strings (f"D{ligne}"), technique indispensable dès qu'on génère des formules en boucle.

La mise en forme conditionnelle déporte la décision de couleur vers Excel : la règle CellIsRule est enregistrée dans le fichier, et Excel recolore automatiquement les cellules qui la satisfont, même si l'utilisateur modifie les valeurs après coup. C'est différent d'appliquer un fill fixe, qui ne réagirait pas aux changements. Le formula=["20"] est passé sous forme de liste car certaines règles prennent plusieurs opérandes.

freeze_panes = "A2" fige tout ce qui est au-dessus et à gauche de la cellule indiquée. A2 fige donc la ligne 1 (l'en-tête). Pour figer aussi la première colonne, on utiliserait B2. C'est un réglage de confort essentiel sur les grands tableaux.


Étape 3 – Insérer un graphique

Sujet

openpyxl sait créer des graphiques natifs Excel, c'est-à-dire de vrais objets graphiques modifiables dans le tableur, pas des images. On génère un histogramme des ventes directement à partir des cellules du tableau.

L'objectif est d'ajouter un graphique en barres qui représente le total par produit.

Énoncé.

  1. Importe BarChart et Reference depuis openpyxl.chart.
  2. Sur le classeur de l'étape 2, crée un objet BarChart et donne-lui un titre et des libellés d'axes.
  3. Définis les données du graphique avec Reference : la colonne des totaux (D), de la ligne d'en-tête à la dernière ligne de données. Inclure l'en-tête permet de nommer la série.
  4. Définis les catégories (les libellés de l'axe horizontal) avec un autre Reference pointant sur la colonne des produits (A).
  5. Associe données et catégories au graphique avec add_data(..., titles_from_data=True) et set_categories(...).
  6. Place le graphique sur la feuille avec feuille.add_chart(graphique, "F2").
  7. Sauvegarde.

Solution

from openpyxl import load_workbook
from openpyxl.chart import BarChart, Reference

classeur = load_workbook("ventes_formules.xlsx")
feuille = classeur["Ventes"]

# Dernière ligne de données (on exclut la ligne TOTAL)
ddonnees_fin = feuille.max_row - 1   # -1 pour ignorer la ligne TOTAL

graphique = BarChart()
graphique.title = "Chiffre d'affaires par produit"
graphique.x_axis.title = "Produit"
graphique.y_axis.title = "Total (€)"
graphique.type = "col"        # colonnes verticales

# Données : colonne D (Total), en-tête inclus pour nommer la série
donnees = Reference(feuille, min_col=4, min_row=1, max_row=donnees_fin)
# Catégories : colonne A (Produit), sans l'en-tête
categories = Reference(feuille, min_col=1, min_row=2, max_row=donnees_fin)

graphique.add_data(donnees, titles_from_data=True)
graphique.set_categories(categories)

feuille.add_chart(graphique, "F2")

classeur.save("ventes_graphique.xlsx")
print("Fichier créé : ventes_graphique.xlsx")

Explication

Reference est la façon dont openpyxl désigne une plage de cellules pour un graphique. On ne passe pas les valeurs elles-mêmes, mais une référence vers la plage : le graphique reste lié aux cellules. Si on modifie une valeur dans Excel, le graphique se met à jour, exactement comme un graphique créé à la main.

La distinction données/catégories correspond aux deux axes. Les données (les hauteurs des barres) viennent de la colonne des totaux. Les catégories (les étiquettes sous chaque barre) viennent de la colonne des produits. On inclut la ligne d'en-tête dans les données et on active titles_from_data=True pour que la série porte le nom « Total » ; on exclut l'en-tête des catégories car on ne veut pas d'une étiquette « Produit » sur l'axe.

On calcule donnees_fin en retirant la ligne TOTAL, sinon le graphique afficherait une barre géante correspondant à la somme, qui écraserait toutes les autres. C'est une erreur fréquente : bien délimiter la plage de données pour exclure les lignes d'agrégat.

Le graphique est ancré à une cellule ("F2") qui fixe son coin supérieur gauche. Comme c'est un objet Excel natif et non une image, l'utilisateur peut ensuite le déplacer, le redimensionner ou changer son type directement dans le tableur.


Étape 4 – Écrire de gros volumes efficacement

Sujet

Écrire des dizaines de milliers de lignes avec openpyxl en mode normal est lent et gourmand en mémoire, car chaque cellule devient un objet Python complet. openpyxl propose un mode optimisé, write_only, qui écrit ligne par ligne sans garder tout le classeur en mémoire.

L'objectif est de générer un fichier de 100 000 lignes en mode écriture seule et de comparer avec l'approche naïve.

Énoncé.

  1. Crée un classeur en mode optimisé avec Workbook(write_only=True).
  2. En mode write_only, il n'y a pas de feuille active par défaut : crée-la avec classeur.create_sheet().
  3. En mode write_only, on ne peut qu'ajouter des lignes avec append(), et seulement dans l'ordre. On ne peut pas revenir en arrière ni adresser une cellule précise. Écris d'abord l'en-tête, puis génère 100 000 lignes de données dans une boucle.
  4. Pour styliser une cellule en mode write_only, il faut créer un objet WriteOnlyCell, lui affecter un style, et l'ajouter. Applique un style gras à l'en-tête de cette manière.
  5. Mesure le temps d'exécution avec le module time.
  6. Sauvegarde et observe la taille et la rapidité.

Solution

import time
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell
from openpyxl.styles import Font

debut = time.perf_counter()

classeur = Workbook(write_only=True)
feuille = classeur.create_sheet("Donnees")

# En-tête stylé (WriteOnlyCell obligatoire pour un style en write_only)
entetes = ["ID", "Nom", "Score"]
ligne_entete = []
for texte in entetes:
    cellule = WriteOnlyCell(feuille, value=texte)
    cellule.font = Font(bold=True)
    ligne_entete.append(cellule)
feuille.append(ligne_entete)

# 100 000 lignes de données
for i in range(1, 100_001):
    feuille.append([i, f"utilisateur_{i}", i % 100])

classeur.save("gros_fichier.xlsx")

duree = time.perf_counter() - debut
print(f"100 000 lignes écrites en {duree:.2f} s")

Explication

En mode normal, openpyxl garde chaque cellule en mémoire sous forme d'objet, avec tous ses attributs de style possibles. Sur 100 000 lignes, cela représente des centaines de milliers d'objets Python, ce qui consomme beaucoup de mémoire et ralentit l'exécution. Le mode write_only change de stratégie : il écrit chaque ligne directement dans le fichier au fur et à mesure, sans conserver l'ensemble en mémoire.

Ce gain a un prix : des contraintes fortes. On ne peut qu'ajouter des lignes, dans l'ordre, avec append(). Impossible de revenir modifier une cellule déjà écrite, de lire une valeur, ou d'adresser une cellule par ses coordonnées. C'est un mode conçu pour un seul usage : produire un gros fichier en flux, du début à la fin. Si le travail exige de revenir en arrière, ce mode ne convient pas.

Le style en mode write_only passe obligatoirement par WriteOnlyCell. Comme on n'a pas d'objet cellule adressable après coup, on construit la cellule avec son style avant de l'ajouter. C'est pour ça que l'en-tête est assemblé dans une liste de WriteOnlyCell plutôt qu'écrit directement.

En sens inverse, pour lire un gros fichier sans saturer la mémoire, openpyxl offre le mode symétrique load_workbook(..., read_only=True), qui parcourt les lignes en flux sans tout charger. Ces deux modes forment la boîte à outils pour les gros volumes.


Pour aller plus loin

  • Créer plusieurs feuilles dans un même classeur avec create_sheet() et naviguer entre elles.
  • Fusionner des cellules avec merge_cells("A1:D1") pour un titre de tableau sur toute la largeur.
  • Appliquer un format de nombre aux cellules monétaires avec cellule.number_format = '# ##0.00 €'.
  • Protéger une feuille ou des cellules contre la modification avec feuille.protection.
  • Combiner pandas et openpyxl : lire/transformer les données avec pandas, puis peaufiner la mise en forme avec openpyxl via pd.ExcelWriter(..., engine="openpyxl").
  • Générer un vrai tableau Excel structuré (avec filtres et style de bande) via openpyxl.worksheet.table.Table.

TP fil rouge – Construire un gestionnaire de tâches en Python

Présentation

Ce TP récapitule les concepts principaux de Python en construisant une seule application du début à la fin : un gestionnaire de tâches en ligne de commande. À chaque étape, l'application gagne une fonctionnalité et introduit un ou plusieurs concepts. On part des types de base et on va jusqu'aux décorateurs et générateurs.

Le code s'accumule : ce qu'on écrit à l'étape 3 sert à l'étape 4. À la fin, on a une application complète et fonctionnelle.

Prérequis

  • Python 3.10 ou plus récent
  • Un éditeur de texte et un terminal
  • Un environnement virtuel actif (voir le TP sur les venv ou uv)

Progression des concepts

  1. Variables, types, chaînes, input/print
  2. Structures de données : listes, dictionnaires
  3. Conditions, boucles, fonctions
  4. Classes et objets
  5. Fichiers et JSON, gestion des exceptions
  6. Concepts avancés : compréhensions, fonctions lambda, décorateurs, générateurs

Crée un fichier taches.py au début et complète-le au fil des étapes.


Étape 1 - Types de base et interaction

Sujet

On commence par les fondations : les variables et leurs types, la manipulation de chaînes de caractères, et l'interaction avec l'utilisateur via input et print. On écrit un petit préambule qui demande le nom de l'utilisateur et affiche un message d'accueil formaté.

L'objectif est de manipuler les types de base (str, int, bool) et le formatage de chaînes avec les f-strings.

Énoncé.

  1. Demande son prénom à l'utilisateur avec input(). input() renvoie toujours une chaîne de caractères.
  2. Nettoie la saisie : retire les espaces en trop avec .strip() et mets la première lettre en majuscule avec .capitalize().
  3. Demande le nombre de tâches prévues aujourd'hui. Comme input() renvoie une chaîne, convertis-la en entier avec int().
  4. Crée une variable booléenne premiere_utilisation = True.
  5. Affiche un message d'accueil avec une f-string qui reprend le prénom et le nombre de tâches. Une f-string s'écrit f"texte {variable} texte".
  6. Affiche le type de chaque variable avec type() pour bien visualiser la différence entre str, int et bool.

Solution

prenom = input("Ton prénom : ").strip().capitalize()
nombre = int(input("Combien de tâches aujourd'hui ? "))
premiere_utilisation = True

print(f"Bonjour {prenom}, tu as {nombre} tâche(s) à gérer.")
print(f"Première utilisation : {premiere_utilisation}")

# Vérification des types
print(type(prenom), type(nombre), type(premiere_utilisation))
# <class 'str'> <class 'int'> <class 'bool'>

Explication

Le point le plus important ici est que input() renvoie systématiquement une chaîne, même si l'utilisateur tape un nombre. Taper 5 donne la chaîne "5", pas l'entier 5. C'est pour ça qu'on entoure la saisie de int() quand on attend un nombre : sans cette conversion, une opération arithmétique dessus échouerait ou donnerait un résultat inattendu ("5" * 2 donne "55", pas 10).

Les méthodes de chaînes comme .strip() et .capitalize() s'enchaînent : chacune renvoie une nouvelle chaîne, sur laquelle on applique la suivante. .strip() retire les espaces au début et à la fin, utile car les utilisateurs en ajoutent souvent par inadvertance. Les chaînes sont immuables en Python : ces méthodes ne modifient pas l'original, elles produisent une nouvelle valeur, qu'on réaffecte à prenom.

Les f-strings sont la façon moderne et lisible d'insérer des variables dans du texte. Ce qui est entre accolades est évalué et inséré. C'est plus clair que la concaténation avec +, qui obligerait en plus à convertir manuellement les nombres en chaînes.

type() renvoie la classe d'une valeur. Le voir explicitement aide à comprendre que Python attribue un type à chaque valeur, même si on ne le déclare pas : c'est le typage dynamique.


Étape 2 - Listes et dictionnaires pour stocker les tâches

Sujet

Une tâche a plusieurs attributs : un intitulé, une priorité, un état (faite ou non). Le dictionnaire est la structure idéale pour représenter un objet à plusieurs champs, et la liste pour en collectionner plusieurs.

L'objectif est de représenter une tâche par un dictionnaire et l'ensemble des tâches par une liste de dictionnaires.

Énoncé.

  1. Représente une tâche par un dictionnaire avec trois clés : "titre" (chaîne), "priorite" (chaîne : "haute", "moyenne", "basse") et "faite" (booléen).
  2. Crée une liste taches contenant trois tâches d'exemple.
  3. Accède à un élément : affiche le titre de la première tâche avec taches[0]["titre"].
  4. Ajoute une nouvelle tâche à la liste avec .append().
  5. Modifie l'état d'une tâche : passe la deuxième tâche à "faite": True.
  6. Affiche le nombre total de tâches avec len(taches).

Solution

taches = [
    {"titre": "Rédiger le rapport", "priorite": "haute", "faite": False},
    {"titre": "Répondre aux mails", "priorite": "moyenne", "faite": False},
    {"titre": "Arroser les plantes", "priorite": "basse", "faite": False},
]

# Accès
print(taches[0]["titre"])          # Rédiger le rapport

# Ajout
taches.append({"titre": "Appeler le client", "priorite": "haute", "faite": False})

# Modification
taches[1]["faite"] = True

print(f"Nombre de tâches : {len(taches)}")   # 4

Explication

Le dictionnaire associe des clés à des valeurs. Ici, chaque tâche est un dictionnaire où la clé "titre" pointe vers l'intitulé, "priorite" vers le niveau, etc. C'est la bonne structure pour un objet à champs nommés : on accède à une information par son nom (tache["titre"]), ce qui est bien plus lisible que par une position numérique.

La liste, elle, est une séquence ordonnée. Elle collectionne les tâches et permet d'y accéder par indice (taches[0] pour la première, l'indexation commençant à 0). La combinaison liste de dictionnaires est un motif omniprésent en Python : c'est d'ailleurs exactement la forme que prend une donnée JSON, ce qui rendra la sauvegarde naturelle à l'étape 5.

Les listes sont mutables : .append() ajoute un élément à la fin, et on peut modifier un élément en place (taches[1]["faite"] = True). Cette mutabilité distingue la liste du tuple, qui est figé une fois créé. On choisit la liste précisément parce que la collection de tâches doit évoluer.

len() fonctionne sur toute séquence et renvoie son nombre d'éléments. C'est une fonction intégrée, disponible sans import.


Étape 3 - Fonctions, conditions et boucles

Sujet

Le code de l'étape 2 manipule les tâches à la main. On organise maintenant ces opérations en fonctions réutilisables, en s'appuyant sur les conditions et les boucles pour parcourir et filtrer la liste.

L'objectif est d'écrire des fonctions pour ajouter, afficher et filtrer les tâches.

Énoncé.

  1. Écris une fonction ajouter_tache(taches, titre, priorite) qui construit le dictionnaire de tâche (avec "faite": False) et l'ajoute à la liste. Elle ne renvoie rien : elle modifie la liste reçue.
  2. Écris une fonction afficher_taches(taches) qui parcourt la liste avec une boucle for et affiche chaque tâche. Utilise enumerate() pour numéroter les tâches à partir de 1.
  3. Dans l'affichage, montre un symbole différent selon l'état : [x] si la tâche est faite, [ ] sinon (condition if/else).
  4. Écris une fonction filtrer_par_priorite(taches, priorite) qui renvoie une nouvelle liste ne contenant que les tâches de la priorité demandée.
  5. Ajoute une valeur par défaut : priorite="moyenne" dans ajouter_tache, pour pouvoir l'appeler sans préciser la priorité.

Solution

def ajouter_tache(taches, titre, priorite="moyenne"):
    taches.append({"titre": titre, "priorite": priorite, "faite": False})


def afficher_taches(taches):
    if not taches:
        print("Aucune tâche.")
        return
    for numero, tache in enumerate(taches, start=1):
        case = "[x]" if tache["faite"] else "[ ]"
        print(f"{numero}. {case} {tache['titre']} ({tache['priorite']})")


def filtrer_par_priorite(taches, priorite):
    resultat = []
    for tache in taches:
        if tache["priorite"] == priorite:
            resultat.append(tache)
    return resultat


# Utilisation
taches = []
ajouter_tache(taches, "Rédiger le rapport", "haute")
ajouter_tache(taches, "Répondre aux mails")          # priorité par défaut
ajouter_tache(taches, "Appeler le client", "haute")

afficher_taches(taches)
print("\nTâches prioritaires :")
afficher_taches(filtrer_par_priorite(taches, "haute"))

Explication

Une fonction regroupe une opération sous un nom réutilisable. ajouter_tache ne renvoie rien (None implicitement) : son travail est de modifier la liste qu'on lui passe. C'est possible parce que les listes sont passées par référence en Python : la fonction reçoit la liste elle-même, pas une copie, donc ses modifications sont visibles à l'extérieur. C'est un point qui surprend souvent au début.

Les paramètres par défaut (priorite="moyenne") rendent une fonction souple : on peut l'appeler avec ou sans ce paramètre. Quand il est omis, la valeur par défaut s'applique. Attention à une règle : ne jamais utiliser d'objet mutable (comme []) comme valeur par défaut, car il serait partagé entre tous les appels ; ici la chaîne "moyenne" est sans danger car immuable.

enumerate(taches, start=1) est la bonne façon de parcourir une liste tout en ayant le numéro de chaque élément. On évite ainsi de gérer un compteur manuel. Le start=1 numérote à partir de 1, plus naturel pour un affichage destiné à un humain.

L'expression "[x]" if tache["faite"] else "[ ]" est un opérateur ternaire : une condition écrite sur une ligne qui renvoie une valeur selon qu'elle est vraie ou fausse. C'est une forme condensée du if/else quand on veut simplement choisir entre deux valeurs.

filtrer_par_priorite illustre le motif « construire une nouvelle liste en parcourant et en testant » : on part d'une liste vide, on parcourt, on ajoute ce qui satisfait la condition. On le réécrira plus court avec une compréhension à l'étape 6.


Étape 4 - Passer aux classes

Sujet

Représenter une tâche par un dictionnaire fonctionne, mais rien n'empêche d'oublier une clé ou d'écrire une faute de frappe. Une classe formalise la structure d'une tâche et regroupe les données avec les opérations qui les concernent.

L'objectif est de créer une classe Tache et une classe GestionnaireTaches qui encapsule la liste et ses opérations.

Énoncé.

  1. Crée une classe Tache avec une méthode __init__(self, titre, priorite="moyenne") qui initialise les attributs titre, priorite et faiteFalse).
  2. Ajoute une méthode marquer_faite(self) qui passe self.faite à True.
  3. Ajoute une méthode __str__(self) qui renvoie une représentation lisible de la tâche (avec le symbole [x]/[ ]). __str__ est appelée automatiquement par print().
  4. Crée une classe GestionnaireTaches avec un attribut self.taches = [] dans son __init__.
  5. Donne-lui les méthodes ajouter(self, titre, priorite) et afficher(self), adaptées de l'étape 3 mais travaillant sur self.taches.
  6. Instancie un gestionnaire, ajoute des tâches, affiche-les.

Solution

class Tache:
    def __init__(self, titre, priorite="moyenne"):
        self.titre = titre
        self.priorite = priorite
        self.faite = False

    def marquer_faite(self):
        self.faite = True

    def __str__(self):
        case = "[x]" if self.faite else "[ ]"
        return f"{case} {self.titre} ({self.priorite})"


class GestionnaireTaches:
    def __init__(self):
        self.taches = []

    def ajouter(self, titre, priorite="moyenne"):
        self.taches.append(Tache(titre, priorite))

    def afficher(self):
        if not self.taches:
            print("Aucune tâche.")
            return
        for numero, tache in enumerate(self.taches, start=1):
            print(f"{numero}. {tache}")


# Utilisation
gestionnaire = GestionnaireTaches()
gestionnaire.ajouter("Rédiger le rapport", "haute")
gestionnaire.ajouter("Répondre aux mails")
gestionnaire.taches[0].marquer_faite()
gestionnaire.afficher()
# 1. [x] Rédiger le rapport (haute)
# 2. [ ] Répondre aux mails (moyenne)

Explication

Une classe est un modèle qui décrit à la fois des données (les attributs) et des comportements (les méthodes). __init__ est le constructeur : la méthode appelée automatiquement quand on crée une instance avec Tache("titre"). Le paramètre self désigne l'instance en cours de création ; self.titre = titre attache la valeur reçue à cette instance précise. Chaque tâche créée a donc ses propres attributs indépendants.

L'intérêt par rapport au dictionnaire est double. D'abord, la structure est garantie : toute Tache a forcément un titre, une priorite et un faite, impossible d'en oublier. Ensuite, les opérations vivent avec les données : marquer_faite est une méthode de la tâche, pas une fonction externe. C'est l'encapsulation, principe central de la programmation orientée objet.

__str__ est une méthode spéciale (reconnaissable à ses doubles tirets bas). Python l'appelle automatiquement quand on passe l'objet à print() ou str(). Sans elle, afficher une tâche montrerait quelque chose comme <Tache object at 0x...>, illisible. En la définissant, on contrôle l'apparence de l'objet.

GestionnaireTaches illustre la composition : un objet qui contient une collection d'autres objets. Il encapsule la liste et expose des méthodes propres, si bien que le reste du programme n'a plus à manipuler la liste directement. On interagit avec le gestionnaire, pas avec ses entrailles.


Étape 5 - Sauvegarde JSON et gestion des exceptions

Sujet

Les tâches disparaissent à la fermeture du programme. On les rend persistantes en les sauvegardant dans un fichier JSON, et on protège la lecture contre les erreurs (fichier absent, contenu corrompu) avec la gestion des exceptions.

L'objectif est d'ajouter la sauvegarde et le chargement des tâches, avec une gestion robuste des cas d'erreur.

Énoncé.

  1. Importe le module json.
  2. Ajoute au gestionnaire une méthode sauvegarder(self, chemin). Comme JSON ne sait pas sérialiser un objet Tache, convertis chaque tâche en dictionnaire (par exemple avec une méthode to_dict sur Tache, ou en utilisant vars(tache) qui renvoie les attributs d'un objet sous forme de dictionnaire).
  3. Ouvre le fichier en écriture avec with open(chemin, "w", encoding="utf-8") as f: et écris avec json.dump(..., f, ensure_ascii=False, indent=2).
  4. Ajoute une méthode charger(self, chemin) qui lit le fichier et reconstruit les objets Tache.
  5. Protège le chargement avec try/except : gère FileNotFoundError (premier lancement, pas encore de fichier) et json.JSONDecodeError (fichier abîmé). Dans ces cas, démarre avec une liste vide plutôt que de planter.
  6. Teste le cycle complet : ajouter, sauvegarder, créer un nouveau gestionnaire, charger.

Solution

import json


class Tache:
    def __init__(self, titre, priorite="moyenne", faite=False):
        self.titre = titre
        self.priorite = priorite
        self.faite = faite

    def to_dict(self):
        return {"titre": self.titre, "priorite": self.priorite, "faite": self.faite}

    def __str__(self):
        case = "[x]" if self.faite else "[ ]"
        return f"{case} {self.titre} ({self.priorite})"


class GestionnaireTaches:
    def __init__(self):
        self.taches = []

    def ajouter(self, titre, priorite="moyenne"):
        self.taches.append(Tache(titre, priorite))

    def sauvegarder(self, chemin):
        donnees = [tache.to_dict() for tache in self.taches]
        with open(chemin, "w", encoding="utf-8") as f:
            json.dump(donnees, f, ensure_ascii=False, indent=2)

    def charger(self, chemin):
        try:
            with open(chemin, "r", encoding="utf-8") as f:
                donnees = json.load(f)
        except FileNotFoundError:
            print("Aucune sauvegarde trouvée, démarrage à vide.")
            self.taches = []
            return
        except json.JSONDecodeError:
            print("Fichier de sauvegarde illisible, démarrage à vide.")
            self.taches = []
            return
        self.taches = [
            Tache(d["titre"], d["priorite"], d["faite"]) for d in donnees
        ]


# Cycle complet
g = GestionnaireTaches()
g.ajouter("Rédiger le rapport", "haute")
g.ajouter("Répondre aux mails")
g.sauvegarder("taches.json")

g2 = GestionnaireTaches()
g2.charger("taches.json")
for tache in g2.taches:
    print(tache)

Explication

JSON est un format texte universel pour échanger des données structurées, et il correspond exactement à la forme « listes et dictionnaires » vue à l'étape 2. Mais json.dump ne sait sérialiser que des types de base (dictionnaires, listes, chaînes, nombres, booléens), pas un objet Tache personnalisé. D'où la conversion en dictionnaire via to_dict() avant la sauvegarde, et la reconstruction des objets Tache au chargement. Cette traduction objet ↔ dictionnaire est le cœur de la sérialisation.

Le bloc with open(...) est la manière correcte d'ouvrir un fichier : il garantit la fermeture automatique du fichier à la fin, même en cas d'erreur. On évite ainsi les fichiers laissés ouverts. Les options encoding="utf-8" et ensure_ascii=False assurent que les accents sont écrits correctement plutôt que sous forme de codes, et indent=2 rend le fichier lisible par un humain.

La gestion des exceptions est ce qui rend le programme robuste face au monde réel. Au premier lancement, le fichier n'existe pas : sans protection, open lèverait FileNotFoundError et le programme planterait. On intercepte cette exception précise pour démarrer proprement à vide. De même, un fichier abîmé lèverait json.JSONDecodeError. On cible chaque type d'erreur séparément plutôt qu'un except général, ce qui permet un message adapté à chaque situation et évite de masquer des bugs imprévus.

L'ordre compte : on ne réaffecte self.taches avec les données chargées qu'après un try réussi. En cas d'échec, on retombe sur la liste vide et on sort avec return.


Étape 6 - Concepts avancés

Sujet

On termine par quatre outils qui rendent le code plus expressif : les compréhensions de liste (déjà entraperçues), les fonctions lambda avec sorted, un décorateur pour mesurer le temps d'exécution, et un générateur pour parcourir les tâches sans tout charger.

L'objectif est d'appliquer ces quatre concepts avancés à l'application existante.

Énoncé.

  1. Compréhension de liste. Réécris filtrer_par_priorite en une seule ligne avec une compréhension : [t for t in self.taches if t.priorite == priorite].
  2. Lambda et tri. Écris une méthode trier_par_priorite qui trie les tâches. Utilise sorted() avec le paramètre key et une fonction lambda qui mappe chaque priorité à un ordre (haute avant moyenne avant basse).
  3. Décorateur. Écris un décorateur chronometre qui mesure et affiche le temps d'exécution de la fonction qu'il décore. Applique-le à une méthode.
  4. Générateur. Écris une méthode taches_a_faire qui est un générateur : elle utilise yield pour produire les tâches non faites une par une, au lieu de construire une liste complète.
  5. Teste chacun de ces éléments.

Solution

import time
from functools import wraps


def chronometre(fonction):
    @wraps(fonction)
    def wrapper(*args, **kwargs):
        debut = time.perf_counter()
        resultat = fonction(*args, **kwargs)
        duree = time.perf_counter() - debut
        print(f"[{fonction.__name__}] exécutée en {duree:.6f} s")
        return resultat
    return wrapper


class GestionnaireTaches:
    def __init__(self):
        self.taches = []

    def ajouter(self, titre, priorite="moyenne"):
        self.taches.append(Tache(titre, priorite))

    # 1. Compréhension de liste
    def filtrer_par_priorite(self, priorite):
        return [t for t in self.taches if t.priorite == priorite]

    # 2. Lambda + sorted
    @chronometre                       # 3. Décorateur appliqué
    def trier_par_priorite(self):
        ordre = {"haute": 0, "moyenne": 1, "basse": 2}
        return sorted(self.taches, key=lambda t: ordre[t.priorite])

    # 4. Générateur
    def taches_a_faire(self):
        for tache in self.taches:
            if not tache.faite:
                yield tache


# Tests
g = GestionnaireTaches()
g.ajouter("Basse priorité", "basse")
g.ajouter("Urgent", "haute")
g.ajouter("Normal", "moyenne")

for tache in g.trier_par_priorite():
    print(tache)

print("\nÀ faire :")
for tache in g.taches_a_faire():       # consommation du générateur
    print(tache)

Explication

La compréhension de liste condense le motif « parcourir, tester, ajouter » de l'étape 3 en une seule expression lisible. [t for t in self.taches if t.priorite == priorite] se lit « prends chaque t de self.taches pour lequel la priorité correspond ». C'est plus court et, une fois l'habitude prise, plus clair que la boucle explicite avec une liste vide et des append.

Une fonction lambda est une fonction anonyme d'une seule expression. lambda t: ordre[t.priorite] prend une tâche et renvoie son rang numérique. Passée comme key à sorted, elle indique sur quel critère trier : Python appelle cette fonction sur chaque élément et trie selon les valeurs renvoyées. Le dictionnaire ordre traduit les priorités textuelles en nombres, car trier des chaînes donnerait l'ordre alphabétique, pas l'ordre d'importance voulu.

Un décorateur enveloppe une fonction pour lui ajouter un comportement sans toucher à son code. chronometre reçoit une fonction, la remplace par un wrapper qui mesure le temps avant et après l'appel, puis renvoie ce wrapper. La syntaxe @chronometre au-dessus d'une méthode applique cet emballage. @wraps préserve le nom et la documentation de la fonction d'origine. C'est le même mécanisme que @lru_cache ou les décorateurs de routes vus dans d'autres TP : une fonction qui transforme une fonction.

Un générateur produit ses valeurs à la demande avec yield au lieu de les construire toutes d'un coup avec return. taches_a_faire ne crée pas de liste : à chaque tour de la boucle qui la consomme, elle reprend là où elle s'était arrêtée et livre la tâche suivante. L'intérêt est la mémoire : sur une collection énorme, un générateur ne garde qu'un élément à la fois en mémoire, alors qu'une liste les stockerait tous. C'est le pendant du mode write_only d'openpyxl, appliqué au parcours de données.


Assemblage final

Réunis les meilleures versions de chaque classe (Tache de l'étape 5, GestionnaireTaches enrichi de l'étape 6) dans taches.py, et ajoute une petite boucle de menu qui utilise input pour piloter l'application :

def menu():
    g = GestionnaireTaches()
    g.charger("taches.json")
    while True:
        print("\n1. Ajouter  2. Afficher  3. Sauvegarder et quitter")
        choix = input("Choix : ").strip()
        if choix == "1":
            titre = input("Titre : ")
            priorite = input("Priorité (haute/moyenne/basse) : ") or "moyenne"
            g.ajouter(titre, priorite)
        elif choix == "2":
            for numero, tache in enumerate(g.trier_par_priorite(), start=1):
                print(f"{numero}. {tache}")
        elif choix == "3":
            g.sauvegarder("taches.json")
            print("Sauvegardé. À bientôt.")
            break
        else:
            print("Choix invalide.")


if __name__ == "__main__":
    menu()

Le bloc if __name__ == "__main__": garantit que menu() ne s'exécute que si le fichier est lancé directement, pas s'il est importé depuis un autre module. C'est la convention standard pour un point d'entrée de programme.


Pour aller plus loin

  • Ajouter une date d'échéance aux tâches avec le module datetime et trier ou filtrer dessus.
  • Écrire des tests avec pytest pour vérifier ajouter, filtrer_par_priorite et la sauvegarde/chargement.
  • Remplacer la validation manuelle des priorités par un modèle Pydantic (voir le TP dédié).
  • Transformer l'interface en API web avec FastAPI (voir le TP sécurisation d'API).
  • Utiliser un Enum pour les priorités au lieu de chaînes libres, afin d'éviter les fautes de frappe.
  • Empaqueter le projet avec uv (uv init, uv add) pour le rendre installable.