Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1823 minDébutant

Python pour le développeur SLAM : client d'API et SQLite

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • écrire un client d'API en ligne de commande avec sous-commandes ;
  • tester un client d'API sans serveur ;
  • interroger une base SQLite avec des requêtes paramétrées ;
  • protéger une modification par une transaction.

3 exercices corrigés

Pour un développeur, Python est à la fois un langage d'application (back-end web, API) et le langage des outils qui entourent une application : un client en ligne de commande pour tester une API, un script qui migre ou contrôle des données, une base locale pour un prototype, des tests. Ce chapitre construit un client de l'API des fiches de frais GSB avec sous-commandes, gestion des erreurs et tests, puis utilise le module sqlite3 de la bibliothèque standard sur deux tables de la base parc du cours SQL, avec des requêtes paramétrées et des transactions.

Ce que Python apporte en SLAM

UsageOutils Python
Écrire une API ou un siteFastAPI, Django, Flask
Tester une API, scripter des appelsrequests, httpx, pytest
Migrer, nettoyer, importer des donnéescsv, json, sqlite3, les pilotes MySQL et PostgreSQL
Prototyper une base sans serveursqlite3, livré avec Python
Automatiser le projet : contrôles, génération de fichiersScripts, argparse, la CI (intégration continue)

Une application GSB ou AppliFrais en PHP, en C# ou en Node.js profite de ces outils sans être écrite en Python : le client de ce chapitre teste une API quelle que soit sa technologie.

Un client en ligne de commande pour l'API GSB

Le client propose trois commandes, lister, voir et creer, grâce aux sous-commandes d'argparse (add_subparsers), comme git commit ou docker run. Il reprend tout le chapitre 13 : jeton dans une variable d'environnement, délai sur chaque appel, pagination, erreurs au format RFC 9457 traduites en message lisible, et un code de retour distinct pour une erreur de l'API (1) et une API injoignable (2).

python
#!/usr/bin/env python3
"""Client en ligne de commande de l'API des fiches de frais GSB."""
import argparse
import os
import sys

import requests

API = os.environ.get("GSB_API", "http://localhost:3000/v1")


class ErreurApi(Exception):
    """Réponse d'erreur de l'API, avec son message lisible."""


def session_api():
    session = requests.Session()
    session.headers.update({"Authorization": f"Bearer {os.environ['GSB_JETON']}",
                            "Accept": "application/json"})
    return session


def verifier(reponse):
    """Lève ErreurApi avec le détail RFC 9457 si la réponse est une erreur."""
    if reponse.ok:
        return reponse.json()
    try:
        probleme = reponse.json()
    except ValueError:                       # corps qui n'est pas du JSON
        raise ErreurApi(f"erreur HTTP {reponse.status_code}") from None
    details = "; ".join(f"{e['champ']} : {e['message']}" for e in probleme.get("erreurs", []))
    raise ErreurApi(f"{reponse.status_code} {probleme.get('title', '')}" + (f" ({details})" if details else ""))


def formater(fiche):
    montant = "" if fiche["montantValide"] is None else f"{fiche['montantValide']:.2f} €"
    return f"#{fiche['id']:<4}{fiche['mois']}  {fiche['etat']}  {fiche['visiteurId']:<5}{montant:>10}"


def lister(session, etat):
    page = 1
    while True:
        corps = verifier(session.get(f"{API}/fiches", params={"etat": etat, "page": page}, timeout=5))
        for fiche in corps["donnees"]:
            print(formater(fiche))
        if page >= corps["pagination"]["pages"]:
            return
        page += 1


def main(arguments=None):
    parser = argparse.ArgumentParser(description=__doc__)
    commandes = parser.add_subparsers(dest="commande", required=True)
    p_lister = commandes.add_parser("lister", help="liste les fiches d'un état")
    p_lister.add_argument("--etat", choices=["CR", "CL", "VA", "RB"], default="CR")
    p_voir = commandes.add_parser("voir", help="affiche une fiche")
    p_voir.add_argument("id", type=int)
    p_creer = commandes.add_parser("creer", help="crée la fiche d'un mois")
    p_creer.add_argument("--mois", required=True, help="AAAA-MM")
    args = parser.parse_args(arguments)

    session = session_api()
    try:
        if args.commande == "lister":
            lister(session, args.etat)
        elif args.commande == "voir":
            print(formater(verifier(session.get(f"{API}/fiches/{args.id}", timeout=5))))
        elif args.commande == "creer":
            fiche = verifier(session.post(f"{API}/fiches", json={"mois": args.mois}, timeout=5))
            print("Fiche créée :", formater(fiche))
    except ErreurApi as erreur:
        print(f"Erreur de l'API : {erreur}", file=sys.stderr)
        return 1
    except requests.RequestException as erreur:
        print(f"API injoignable : {erreur.__class__.__name__}", file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main())

Contre l'API de démonstration du chapitre 13 (api_gsb_demo.py, lancée dans un autre terminal) :

bash
python gsb.py lister --etat VA
python gsb.py voir 7
python gsb.py voir 999
python gsb.py creer --mois 2026-13
text
#2   2026-08  VA  2      615.40 €
#5   2026-08  VA  5      893.05 €
#7   2026-08  VA  7      742.18 €
#11  2026-08  VA  11     528.90 €
#7   2026-08  VA  7      742.18 €
Erreur de l'API : 404 Fiche introuvable
Code de retour : 1
Erreur de l'API : 422 Données invalides (mois : Format attendu : AAAA-MM)
Code de retour : 1

Avec l'API arrêtée, puis avec un état qui n'existe pas :

text
API injoignable : ConnectionError
Code de retour : 2
usage: gsb.py lister [-h] [--etat {CR,CL,VA,RB}]
gsb.py lister: error: argument --etat: invalid choice: 'XX' (choose from 'CR', 'CL', 'VA', 'RB')
Code de retour : 2

Trois choix de conception. L'exception ErreurApi isole la traduction des erreurs dans une seule fonction, verifier() : les commandes n'ont pas à connaître le format des erreurs. raise … from None masque l'exception d'origine (le JSON illisible) dans le traceback, sans intérêt pour l'utilisateur. Et choices= refuse un état inconnu avant tout appel réseau.

Tester le client sans serveur

formater() et verifier() ne dépendent pas du réseau : elles se testent avec de fausses réponses, construites à la main (requests.Response avec un code et un corps).

python
import pytest
import requests

import gsb


def test_formater_une_fiche_validee():
    fiche = {"id": 7, "mois": "2026-08", "etat": "VA", "visiteurId": 7, "montantValide": 742.18}
    assert gsb.formater(fiche) == "#7   2026-08  VA  7      742.18 €"


def faux_reponse(statut, corps):
    reponse = requests.Response()
    reponse.status_code = statut
    reponse._content = corps.encode("utf-8")
    return reponse


def test_verifier_traduit_une_erreur_de_validation():
    corps = ('{"title": "Données invalides", "status": 422,'
             ' "erreurs": [{"champ": "mois", "message": "Format attendu : AAAA-MM"}]}')
    with pytest.raises(gsb.ErreurApi, match="422 Données invalides .mois : Format attendu"):
        gsb.verifier(faux_reponse(422, corps))


def test_verifier_sans_corps_json():
    with pytest.raises(gsb.ErreurApi, match="erreur HTTP 502"):
        gsb.verifier(faux_reponse(502, "<html>Bad Gateway</html>"))
text
...                                                                      [100%]
3 passed in 0.03s

Le motif match= est une expression régulière : le . devant mois remplace la parenthèse, qui aurait un sens spécial. Ces tests tournent en quelques centièmes de seconde, sans API, dans n'importe quel pipeline. Les tests de l'API elle-même, côté serveur, sont l'affaire du cours Tests.

sqlite3 : une base sans serveur

SQLite est un moteur de base de données relationnelle qui tient dans un fichier, sans serveur à installer ; le module sqlite3 est livré avec Python. On le trouve dans les applications mobiles, les navigateurs, et c'est l'outil idéal pour un prototype, des tests ou un script qui a besoin de requêtes SQL sur des données locales (SQL et bases relationnelles). On crée deux tables de la base parc du cours SQL, service et employe, avec une partie de ses données :

python
# base.py : une base SQLite avec deux tables de la base parc du cours SQL
import sqlite3

SERVICES = [(1, "DIR", "Direction", 3), (2, "COMPTA", "Comptabilité", 2), (3, "COMM", "Commercial", 1),
            (4, "LOGI", "Logistique", 0), (5, "INFO", "Informatique", 2), (6, "JURI", "Juridique", None)]
EMPLOYES = [
    (1, "Garnier", "Hélène", "helene.garnier@sorbier.example", "2012-03-01", 1),
    (7, "Faure", "Nadia", "nadia.faure@sorbier.example", "2013-06-03", 4),
    (9, "Bonnet", "Inès", "ines.bonnet@sorbier.example", "2022-10-03", 4),
    (10, "Mercier", "Antoine", "antoine.mercier@sorbier.example", "2016-11-02", 5),
    (11, "Lambert", "Chloé", "chloe.lambert@sorbier.example", "2020-09-01", 5),
    (12, "Diallo", "Moussa", "moussa.diallo@sorbier.example", "2023-09-04", 5),
    (14, "Martin", "Paul", "paul.martin@sorbier.example", "2026-09-01", 2),
]

connexion = sqlite3.connect("parc.db")          # le fichier est créé s'il n'existe pas
connexion.execute("PRAGMA foreign_keys = ON")    # SQLite ne vérifie les clés étrangères que sur demande
connexion.executescript("""
    DROP TABLE IF EXISTS employe;
    DROP TABLE IF EXISTS service;
    CREATE TABLE service (
        id_service INTEGER PRIMARY KEY,
        code       TEXT NOT NULL UNIQUE,
        libelle    TEXT NOT NULL,
        etage      INTEGER
    );
    CREATE TABLE employe (
        id_employe    INTEGER PRIMARY KEY,
        nom           TEXT NOT NULL,
        prenom        TEXT NOT NULL,
        email         TEXT NOT NULL UNIQUE,
        date_embauche TEXT NOT NULL,
        id_service    INTEGER NOT NULL REFERENCES service (id_service)
    );
""")
with connexion:                                  # une transaction : tout ou rien
    connexion.executemany("INSERT INTO service VALUES (?, ?, ?, ?)", SERVICES)
    connexion.executemany("INSERT INTO employe VALUES (?, ?, ?, ?, ?, ?)", EMPLOYES)
print(connexion.execute("SELECT COUNT(*) FROM employe").fetchone()[0], "employés enregistrés")
connexion.close()
text
7 employés enregistrés

executemany() exécute la même requête pour chaque tuple de la liste. Le bloc with connexion: ouvre une transaction : à la sortie du bloc, elle est validée (COMMIT) si tout s'est bien passé, annulée (ROLLBACK) si une exception a été levée (transactions). Les types de SQLite sont souples (TEXT, INTEGER) : les dates s'y rangent en texte ISO, qui se trie correctement.

Des requêtes paramétrées

Chaque valeur qui vient de l'extérieur (formulaire, fichier, argument) passe par un paramètre ?, jamais collée dans le texte de la requête :

python
# requetes.py : interroger la base avec des requêtes paramétrées
import sqlite3

connexion = sqlite3.connect("parc.db")
connexion.row_factory = sqlite3.Row             # lignes accessibles par nom de colonne

code = "INFO"                                     # imagine une valeur saisie dans un formulaire
lignes = connexion.execute(
    """SELECT e.prenom, e.nom, e.date_embauche
         FROM employe e JOIN service s ON s.id_service = e.id_service
        WHERE s.code = ?
        ORDER BY e.date_embauche""",
    (code,),
).fetchall()
for ligne in lignes:
    print(f"{ligne['prenom']} {ligne['nom']}, depuis le {ligne['date_embauche']}")

for libelle, effectif in connexion.execute(
        """SELECT s.libelle, COUNT(e.id_employe)
             FROM service s LEFT JOIN employe e ON e.id_service = s.id_service
            GROUP BY s.id_service ORDER BY s.libelle"""):
    print(f"{libelle:<14}{effectif}")
connexion.close()
text
Antoine Mercier, depuis le 2016-11-02
Chloé Lambert, depuis le 2020-09-01
Moussa Diallo, depuis le 2023-09-04
Commercial    0
Comptabilité  1
Direction     1
Informatique  3
Juridique     0
Logistique    2

execute(requete, (code,)) envoie la requête et ses valeurs séparément : la base analyse d'abord la requête, puis y place les valeurs, qui ne peuvent jamais devenir du SQL. Attention à la virgule de (code,) : c'est elle qui fait un tuple d'un seul élément. row_factory = sqlite3.Row permet de lire les colonnes par leur nom ; une connexion parcourue dans un for donne les lignes une à une.

La démonstration de ce qui arrive sinon tient en un nom de famille :

python
# apostrophe.py : pourquoi on ne colle jamais une valeur dans le texte d'une requête
import sqlite3

connexion = sqlite3.connect("parc.db")
connexion.execute("PRAGMA foreign_keys = ON")
nom = "N'Diaye"

try:
    connexion.execute(f"SELECT * FROM employe WHERE nom = '{nom}'")      # à ne jamais faire
except sqlite3.OperationalError as erreur:
    print("Requête construite par concaténation :", erreur)

print("Requête paramétrée :", connexion.execute("SELECT COUNT(*) FROM employe WHERE nom = ?",
                                                (nom,)).fetchone()[0], "résultat")

try:
    with connexion:
        connexion.execute("INSERT INTO employe VALUES (?, ?, ?, ?, ?, ?)",
                          (15, "N'Diaye", "Zoë", "zoe.ndiaye@sorbier.example", "2026-11-09", 4))
        connexion.execute("INSERT INTO employe VALUES (?, ?, ?, ?, ?, ?)",
                          (16, "Marchal", "Théo", "zoe.ndiaye@sorbier.example", "2026-11-09", 4))
except sqlite3.IntegrityError as erreur:
    print("Transaction annulée :", erreur)
print(connexion.execute("SELECT COUNT(*) FROM employe").fetchone()[0], "employés")
connexion.close()
text
Requête construite par concaténation : near "Diaye": syntax error
Requête paramétrée : 0 résultat
Transaction annulée : UNIQUE constraint failed: employe.email
7 employés

L'apostrophe de N'Diaye ferme la chaîne SQL au milieu du nom : la requête collée est invalide. Ce qui casse avec une apostrophe innocente peut être exploité avec une saisie choisie pour modifier le sens de la requête : c'est l'injection SQL, et la requête paramétrée en est la parade (injection SQL et requêtes préparées). La seconde partie montre la transaction : le deuxième INSERT viole l'unicité de l'adresse électronique, et le premier est annulé avec lui. La base garde ses 7 employés, au lieu d'une moitié d'opération.

Pour aller plus loin : les frameworks web

Pour écrire l'API elle-même en Python, trois frameworks dominent. FastAPI construit une API à partir de fonctions annotées, valide les données et génère la documentation OpenAPI (documenter une API). Django fournit tout pour un site complet : ORM, interface d'administration, authentification. Flask est un micro-framework, minimal et extensible. Le cours API REST explique les principes communs à tous ; ce cours-ci te donne le langage pour t'y lancer.

Exercices

Exercice 1 sur 3 La sous-commande total Réussi À revoir

Écris une fonction total(session, etat) qui renvoie le nombre de fiches d'un état et la somme de leurs montants validés, toutes pages confondues, en réutilisant verifier() et la session du client. Une fiche non validée a un montant None. Affiche le résultat pour les états VA et CL.

Voir la réponseMasquer la réponse de l'exercice 1
python
# ex_total.py : la sous-commande « total », à ajouter au client gsb.py
import os
import sys

import gsb


def total(session, etat):
    """Nombre de fiches de l'état et somme de leurs montants validés, toutes pages confondues."""
    nombre, somme, page = 0, 0.0, 1
    while True:
        corps = gsb.verifier(session.get(f"{gsb.API}/fiches", params={"etat": etat, "page": page}, timeout=5))
        for fiche in corps["donnees"]:
            nombre += 1
            somme += fiche["montantValide"] or 0     # None (pas encore validée) compte pour 0
        if page >= corps["pagination"]["pages"]:
            return nombre, somme
        page += 1


if __name__ == "__main__":
    etat = sys.argv[1] if len(sys.argv) > 1 else "VA"
    nombre, somme = total(gsb.session_api(), etat)
    print(f"{nombre} fiche(s) {etat} : {somme:.2f} €")
text
4 fiche(s) VA : 2779.53 €
47 fiche(s) CL : 0.00 €

fiche["montantValide"] or 0 remplace None par 0 : les fiches clôturées n'ont pas encore de montant validé. Intégrée au client, la fonction devient une quatrième sous-commande : commandes.add_parser("total"), puis une branche elif args.commande == "total": dans main().

Tu avais trouvé ?

Exercice 2 sur 3 Les recrues récentes Réussi À revoir

Avec la base parc.db de ce chapitre, affiche le nombre d'employés embauchés depuis le 1ᵉʳ janvier 2020 dans chaque service, du service qui en compte le plus à celui qui en compte le moins. La date doit être un paramètre de la requête.

Voir la réponseMasquer la réponse de l'exercice 2
python
# ex_anciennete.py : les employés embauchés depuis une date donnée, par service
import sqlite3

depuis = "2020-01-01"

connexion = sqlite3.connect("parc.db")
requete = """
    SELECT s.libelle, COUNT(*) AS recrues
      FROM employe e JOIN service s ON s.id_service = e.id_service
     WHERE e.date_embauche >= ?
     GROUP BY s.libelle
     ORDER BY recrues DESC, s.libelle
"""
for libelle, recrues in connexion.execute(requete, (depuis,)):
    print(f"{libelle:<14}{recrues}")
connexion.close()
text
Informatique  2
Comptabilité  1
Logistique    1

Les dates ISO se comparent comme du texte, d'où >= ? directement. La jointure interne ne garde que les services qui ont au moins une recrue ; avec un LEFT JOIN, il faudrait déplacer la condition de date dans le ON pour garder les services à zéro (jointures externes).

Tu avais trouvé ?

Exercice 3 sur 3 Revue de code Réussi À revoir

Un collègue propose cette fonction de recherche pour le back-office. Explique le problème et corrige-la.

python
def chercher(connexion, nom):
    return connexion.execute(f"SELECT * FROM employe WHERE nom LIKE '%{nom}%'").fetchall()
Voir la réponseMasquer la réponse de l'exercice 3

La valeur saisie est collée dans le texte de la requête : une apostrophe la casse (comme N'Diaye), et une saisie construite exprès peut changer le sens de la requête, c'est-à-dire lire ou modifier d'autres données. La correction passe la valeur, jokers compris, en paramètre :

python
def chercher(connexion, nom):
    return connexion.execute("SELECT * FROM employe WHERE nom LIKE ?", (f"%{nom}%",)).fetchall()

Essayée sur la base du chapitre avec "Dia", puis "N'Di" :

text
[(12, 'Diallo', 'Moussa', 'moussa.diallo@sorbier.example', '2023-09-04', 5)]
[]

La f-string ne sert plus qu'à construire la valeur %N'Di%, jamais le texte SQL : l'apostrophe ne gêne plus, la recherche répond simplement qu'il n'y a personne. Le compte qui exécute la requête n'a besoin que du droit de lecture : le moindre privilège limite les dégâts si une faille subsiste ailleurs (comptes et droits).

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Aide-mémoire PythonRevoir le sommaire du cours