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
| Usage | Outils Python |
|---|---|
| Écrire une API ou un site | FastAPI, Django, Flask |
| Tester une API, scripter des appels | requests, httpx, pytest |
| Migrer, nettoyer, importer des données | csv, json, sqlite3, les pilotes MySQL et PostgreSQL |
| Prototyper une base sans serveur | sqlite3, livré avec Python |
| Automatiser le projet : contrôles, génération de fichiers | Scripts, 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).
#!/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) :
python gsb.py lister --etat VA
python gsb.py voir 7
python gsb.py voir 999
python gsb.py creer --mois 2026-13
#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 :
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).
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>"))
... [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 :
# 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()
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 :
# 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()
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 :
# 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()
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
# 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} €")
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
# 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()
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.
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 :
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" :
[(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é ?