Leçon 09-3 — JSON et APIs publiques
1. Objectif
À la fin de cette leçon tu sauras : lire mentalement n'importe quelle structure JSON comme un empilement de dicts et de listes Python, naviguer dans du JSON profondément imbriqué sans te perdre, sauvegarder et recharger des données avec json.dump/json.load (fichiers) et json.dumps/json.loads (chaînes), et écrire du code défensif qui survit aux clés manquantes et aux structures inattendues des APIs réelles.
2. Pourquoi c'est important
JSON est la langue commune des données : réponses d'APIs, fichiers de configuration (VS Code, npm…), exports d'applications, logs structurés. Tout programme moderne en lit et en écrit. La bonne nouvelle : tu connais déjà 90 % du sujet, parce que le JSON EST le mariage des dicts et des listes du niveau 03. Le vrai savoir-faire de cette leçon, celui qui distingue le code de tuto du code réel, c'est la défense : les données des autres ne respectent jamais complètement leur propre documentation — un champ absent ici, une liste vide là. Coder comme si le JSON était parfait, c'est planter en production.
3. Explication simple
Le JSON (JavaScript Object Notation) est du texte qui décrit des données. Le voici face à son équivalent Python :
JSON Python
---- ------
{"city": "Nice"} {"city": "Nice"} dict
[1, 2, 3] [1, 2, 3] list
"texte" "texte" str
42, 3.14 42, 3.14 int, float
true / false True / False bool (minuscule en JSON !)
null None
Presque identiques. Le module json fait la traduction dans les deux sens, avec 4 fonctions — 2 pour les fichiers, 2 pour les chaînes :
import json
json.load(f) # fichier JSON → objets Python (load = charger)
json.dump(d, f) # objets Python → fichier JSON (dump = déverser)
json.loads(s) # chaîne JSON → objets Python (le s = string)
json.dumps(d) # objets Python → chaîne JSON
Moyen mnémotechnique : s final = string. Sans s = fichier.
Et naviguer dans du JSON imbriqué, c'est enchaîner des crochets, un étage à la fois :
data = {"daily": {"temperature_2m_max": [29.1, 28.4, 27.9]}}
data["daily"]["temperature_2m_max"][0] # 29.1 : dict → dict → liste → élément
4. Explication approfondie
a) JSON est du texte, pas des objets. Un fichier .json ou un corps de réponse HTTP, c'est une chaîne de caractères. Tant qu'elle n'est pas passée par json.loads (ou response.json(), qui fait exactement ça pour toi), tu ne peux pas mettre de crochets dessus. À l'inverse, json.dumps produit une chaîne — c'est pour ça qu'on peut l'écrire dans un fichier ou l'envoyer sur le réseau. Traduction ↔ texte : c'est ce qu'on appelle sérialiser / désérialiser.
b) La méthode pour explorer un JSON inconnu. Face à une réponse d'API de 200 lignes, ne devine pas : sonde étage par étage dans le terminal.
data = response.json()
type(data) # dict ? list ?
data.keys() # si dict : quelles clés ?
type(data["daily"]) # et à l'étage du dessous ?
data["daily"].keys()
Règle de lecture : { ouvre un dict (accès par clé), [ ouvre une liste (accès par indice). Un chemin comme data["daily"]["time"][0] se lit : « dans le dict, la clé daily ; dans ce dict, la clé time ; dans cette liste, l'élément 0 ».
c) Le piège des « colonnes parallèles ». Beaucoup d'APIs (dont Open-Meteo) ne renvoient pas une liste de jours, mais des listes parallèles : daily["time"] contient les dates, daily["temperature_2m_max"] les maximales, et c'est l'indice qui les relie (la date [2] va avec la température [2]). Pour les parcourir ensemble : zip() (⏩ croisé au niveau 03) :
for date, tmax in zip(daily["time"], daily["temperature_2m_max"]):
print(date, tmax)
d) Coder défensif : .get() et compagnie. Sur du JSON réel, une clé peut manquer — champ optionnel, cas d'erreur, version d'API. data["results"] plante avec KeyError ; data.get("results") renvoie None ; data.get("results", []) renvoie une liste vide — souvent le meilleur choix, car le for qui suit fera simplement zéro tour. Le modèle robuste :
results = data.get("results", [])
if not results:
print("Aucun résultat.")
return None
first = results[0]
Nuance importante : .get() partout, c'est TROP défensif. Si une clé est garantie par l'API et que son absence signifie un vrai problème, un KeyError bruyant vaut mieux qu'un None qui se propage silencieusement et fait planter trois fonctions plus loin, là où on ne comprend plus rien. Défends-toi aux frontières (ce qui vient du réseau, de l'utilisateur), sois strict à l'intérieur.
e) Écrire du JSON : les deux options qui changent tout.
json.dump(data, f, ensure_ascii=False, indent=2)
indent=2: sans lui, tout sort sur UNE ligne (compact, illisible pour un humain). Avec, le fichier est indenté, lisible, diffable dans Git.ensure_ascii=False: par défaut,jsonéchappe tout caractère non-ASCII —"météo"devient"météo". C'est du JSON valide mais illisible. Avecensure_ascii=False(et un fichier ouvert enencoding="utf-8"), les accents restent des accents.
f) Tout ne se sérialise pas. json.dumps({"when": datetime.now()}) → TypeError: Object of type datetime is not JSON serializable. Le JSON ne connaît que ses 6 types (dict, liste, chaîne, nombre, booléen, null). Convertis d'abord : str(datetime.now()), ou stocke des types simples dès le départ.
Difficulté honnête : la navigation imbriquée demande de la gymnastique — prévois de sécher un peu sur les chemins à 4 étages, c'est normal, tout le monde compte les crochets au début. La méthode de sondage du (b) est ton filet : personne ne lit un JSON de 200 lignes d'un coup, pas même les professionnels.
5. Exemples commentés
Exemple 1 — chaîne JSON → Python, et retour
import json
raw = '{"city": "Nice", "temp": 26.7, "sunny": true, "rain": null}'
data = json.loads(raw) # chaîne → dict
print(data["city"]) # Nice
print(data["sunny"], data["rain"]) # True None ← true→True, null→None
back = json.dumps(data)
print(back) # {"city": "Nice", "temp": 26.7, "sunny": true, "rain": null}
print(type(back)) # <class 'str'> ← une CHAÎNE, pas un dict
Exemple 2 — sauvegarder et recharger un fichier JSON
import json
report = {
"city": "Nice",
"generated": "2026-07-05",
"temperatures": [26.7, 25.1, 24.8],
"source": "open-meteo",
}
# écrire : dump (sans s) + options lisibilité
with open("report.json", "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
# relire : load (sans s)
with open("report.json", encoding="utf-8") as f:
loaded = json.load(f)
print(loaded == report) # True : aller-retour sans perte
print(loaded["temperatures"][0]) # 26.7
Contenu de report.json (lisible grâce à indent=2) :
{
"city": "Nice",
"generated": "2026-07-05",
"temperatures": [26.7, 25.1, 24.8],
"source": "open-meteo"
}
Exemple 3 — sonder puis naviguer une vraie réponse d'API
import requests
response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": 43.7, "longitude": 7.27,
"daily": "temperature_2m_min,temperature_2m_max",
"forecast_days": 3,
"timezone": "auto",
},
timeout=10,
)
response.raise_for_status()
data = response.json()
print(sorted(data.keys()))
# ['daily', 'daily_units', 'elevation', 'generationtime_ms', 'latitude',
# 'longitude', 'timezone', 'timezone_abbreviation', 'utc_offset_seconds']
print(sorted(data["daily"].keys()))
# ['temperature_2m_max', 'temperature_2m_min', 'time']
print(data["daily"]["time"])
# ['2026-07-05', '2026-07-06', '2026-07-07'] ← listes parallèles !
Exemple 4 — les listes parallèles assemblées avec zip
daily = data["daily"] # suite de l'exemple 3
for date, tmin, tmax in zip(
daily["time"],
daily["temperature_2m_min"],
daily["temperature_2m_max"],
):
print(f"{date} : {tmin} à {tmax} °C")
Sortie :
2026-07-05 : 22.6 à 28.9 °C
2026-07-06 : 21.8 à 28.2 °C
2026-07-07 : 22.1 à 27.5 °C
Trois listes, un zip, et l'indice qui reliait les données devient invisible : chaque tour de boucle reçoit un jour complet.
Exemple 5 — le géocodage défensif (clé absente = cas NORMAL)
import requests
def find_city(name):
"""Return (name, country, lat, lon) or None if the city is unknown."""
response = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": name, "count": 1, "language": "fr"},
timeout=10,
)
response.raise_for_status()
data = response.json()
results = data.get("results", []) # ville inconnue → PAS de clé "results"
if not results:
return None
first = results[0]
return (first["name"], first.get("country", "?"),
first["latitude"], first["longitude"])
print(find_city("Nice")) # ('Nice', 'France', 43.70313, 7.26608)
print(find_city("Zzzzzz")) # None — pas de KeyError, pas de crash
Note le dosage : .get("results", []) et .get("country", "?") pour les champs réellement optionnels ; first["latitude"] en accès direct, car un résultat sans coordonnées serait une anomalie qui MÉRITE de planter bruyamment.
Exemple 6 — cache local : API → fichier → relecture
import json
import requests
from pathlib import Path
CACHE = Path("forecast_cache.json")
def get_forecast():
"""Fetch the forecast once; reuse the local cache afterwards."""
if CACHE.exists(): # ⏩ pathlib : niveau 05
print("(lecture du cache, zéro appel réseau)")
with open(CACHE, encoding="utf-8") as f:
return json.load(f)
response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": 43.7, "longitude": 7.27,
"daily": "temperature_2m_max", "forecast_days": 3},
timeout=10,
)
response.raise_for_status()
data = response.json()
with open(CACHE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return data
data = get_forecast() # 1er lancement : appel réseau + écriture du cache
data = get_forecast() # 2e lancement : "(lecture du cache, zéro appel réseau)"
Le duo json.dump/json.load transforme n'importe quelle réponse d'API en fichier réutilisable : moins d'appels réseau, développement plus rapide, et politesse envers l'API.
6. Erreurs fréquentes
1) Oublier de parser : mettre des crochets sur du texte
raw = '{"city": "Nice"}'
print(raw["city"])
# TypeError: string indices must be integers, not 'str'
raw est une chaîne ; raw[...] n'accepte que des indices numériques. Ce TypeError précis = signature du JSON pas encore parsé. Correction : data = json.loads(raw) d'abord.
2) KeyError sur une clé « qui existe dans la doc »
data = response.json()
for city in data["results"]: # KeyError: 'results'
...
La doc montre le cas nominal ; la réponse « ville inconnue » n'a pas cette clé. Correction : data.get("results", []) + test de vide (exemple 5). Réflexe : chaque clé venant du réseau mérite la question « peut-elle manquer ? ».
3) Confondre dump/dumps et leurs arguments
json.dump(data) # TypeError: dump() missing 1 required argument: 'fp'
json.dumps(data, f) # TypeError (le 2e argument n'est pas un fichier ici)
dump/load exigent un fichier ouvert ; dumps/loads travaillent en chaînes. Le s final = string. Si tu écris f.write(json.dumps(data)), ce n'est pas faux — c'est juste json.dump(data, f) en plus long.
4) Écraser accents et lisibilité en écrivant
json.dump({"ville": "Besançon"}, f)
# fichier : {"ville": "Besançon"} — tout sur une ligne, accents échappés
Pas d'erreur : du JSON valide mais hostile. Correction systématique pour des fichiers destinés à des humains : json.dump(data, f, ensure_ascii=False, indent=2) avec open(..., "w", encoding="utf-8").
5) Indexer un dict comme une liste (et vice versa)
daily = data["daily"]
print(daily[0])
# KeyError: 0 ← daily est un DICT ; 0 est cherché comme une clé
print(data["daily"]["time"]["2026-07-05"])
# TypeError: list indices must be integers or slices, not str ← time est une LISTE
Les deux tracebacks te disent lequel tu as sous la main : KeyError: 0 = c'était un dict ; list indices must be integers = c'était une liste. Correction : sonde avec type(...) avant d'écrire le chemin (méthode 4-b).
6) Sérialiser un objet non-JSON
from datetime import datetime
json.dumps({"generated": datetime.now()})
# TypeError: Object of type datetime is not JSON serializable
JSON ne connaît pas les objets Python riches (datetime, Path, set, tes classes…). Correction : convertir avant — {"generated": str(datetime.now())} ou datetime.now().isoformat().
7. Lire les messages d'erreur
json.decoder.JSONDecodeError — le texte n'est pas du JSON valide
Traceback (most recent call last):
File "C:\Users\jtron\Claude\learnpython\cache.py", line 6, in <module>
data = json.loads(raw)
...
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
Lecture :
- Dernière ligne : le problème ET sa position —
line 1 column 2.Expecting property name enclosed in double quotes= le JSON exige des guillemets doubles ; ce texte contenait{'city': 'Nice'}(apostrophes à la Python) — copié-collé d'unprint(dict), erreur ultra-classique. Unprint(dict)Python n'est PAS du JSON. - Autre message fréquent :
Expecting value: line 1 column 1 (char 0)= le texte est vide ou commence par autre chose que du JSON (page HTML d'erreur, fichier cache vide après un crash…). Affiche les 100 premiers caractères du texte (print(raw[:100])) pour voir ce que tu essaies VRAIMENT de parser. response.json()lève la même erreur (viarequests.exceptions.JSONDecodeError, qui en hérite) quand le corps HTTP n'est pas du JSON — retour leçon 09-2 : vérifier le statut d'abord.
KeyError sur du JSON — retrouver l'étage fautif
File "C:\Users\jtron\Claude\learnpython\weather.py", line 12, in <module>
country = data["results"][0]["country_code"]
~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
KeyError: 'country_code'
Python 3 souligne le segment exact : ici les ^^^ pointent ["country_code"] — les étages précédents (data["results"][0]) ont fonctionné. Diagnostic : print(data["results"][0].keys()) pour voir les clés réellement présentes à cet étage. La clé s'appelait peut-être "country", ou n'existe pas pour ce pays-là.
8. Exercices — faciles
Règles : fichiers dans
exercices/mes-reponses/, nommésex-09-3-a.py, etc. Les exercices (a) et (b) fonctionnent hors ligne ; le venv avecrequestsn'est nécessaire qu'à partir de (c).
a) Voici une chaîne JSON : '{"name": "Ada", "languages": ["python", "sql"], "active": true, "score": null}'. Parse-la, puis affiche : le nom, le NOMBRE de langages, le premier langage, et le type Python de active et de score. Prédis les deux types avant d'exécuter.
b) Construis en Python un dict profile te représentant (nom, ville, liste d'objectifs, dict progress avec 2-3 niveaux terminés). Écris-le dans profile.json — accents intacts et fichier indenté exigés. Rouvre le fichier dans VS Code pour vérifier à l'œil, puis recharge-le avec json.load et vérifie loaded == profile.
c) Reprends l'appel Open-Meteo de l'exemple 3 et sonde la réponse SANS la lire en entier : affiche type(data), sorted(data.keys()), puis le type et les clés (ou la longueur) de data["daily"] et data["daily"]["time"]. Écris ensuite, en commentaire, le chemin complet vers la température maximale du 2e jour, et vérifie-le.
9. Exercices — moyens
a) Écris summarize_forecast(data) qui reçoit le dict d'une réponse Open-Meteo daily (min + max, 5 jours, forecast_days=5) et affiche une ligne par jour : 2026-07-06 : 21.8 à 28.2 °C (amplitude 6.4). zip obligatoire, amplitude calculée et arrondie (round(x, 1)). Bonus : termine par la journée la plus chaude de la période.
b) Robustifie find_city (exemple 5) : ajoute le paramètre count=5 et fais-la renvoyer la LISTE de tous les résultats (name, country, lat, lon). Cas à survivre, à tester réellement : ville inexistante (pas de clé results), et résultat sans clé country (mets "?"). Teste avec "Nice" (plusieurs Nice dans le monde !), et affiche-les numérotés.
c) Le cache horodaté : reprends l'exemple 6 et ajoute une entrée "cached_at" (via datetime.now().isoformat(), ⏩ niveau 05) écrite DANS le fichier de cache à côté des données (structure {"cached_at": ..., "data": ...}). À la lecture, affiche l'âge du cache. Le piège de sérialisation de la section 6-6 est sur ton chemin — contourne-le proprement.
10. Exercices — difficiles
a) Écris deep_get(data, path, default=None) où path est une chaîne comme "daily.temperature_2m_max.0" : la fonction descend les étages séparés par des points, traverse dicts ET listes (un segment numérique = indice de liste), et renvoie default si N'IMPORTE QUEL étage manque — jamais d'exception. Teste sur la réponse météo réelle : deep_get(data, "daily.time.1"), deep_get(data, "daily.humidity.0", "n/a"), deep_get(data, "results.0.name", "?").
- Indice 1 :
path.split(".")te donne la liste des segments ; avance dans la structure avec une boucle, une variablecurrentà la main. - Indice 2 : pour chaque segment, trois cas :
currentest un dict (la clé y est-elle ?), une liste (le segment est-il un indice valide ? —segment.isdigit()puis borne), autre chose (échec). - Indice 3 : au moindre cas d'échec,
return defaultimmédiatement ; si la boucle se termine,return current.
b) Compare deux villes : un script qui géocode deux noms passés en arguments, récupère leurs prévisions à 3 jours en parallèle de structure (mais appels successifs, espacés d'une seconde), puis produit un comparison.json propre : pour chaque ville, nom résolu, pays, et liste de {"date": ..., "min": ..., "max": ...} (transforme les listes parallèles en liste de dicts — le format le plus lisible pour un humain qui ouvrira le fichier). Toute panne réseau ou ville inconnue doit produire un message clair, pas un traceback.
11. Mini-projet lié — finir « météo du terminal »
Fiche complète : projects/mini-projects/11-meteo-api.md.
Avec le JSON imbriqué et le code défensif, tu as tout pour terminer le projet commencé en 09-2 (45 min) : ajoute à weather.py les prévisions à 3 jours (min/max par jour, via zip sur les listes parallèles), traduis les weather_code en texte (0 = ciel clair, 1-3 = nuageux, 61-65 = pluie… — construis ton dict de correspondance depuis la doc Open-Meteo), et blinde chaque accès au JSON. Test d'acceptation : python weather.py Nice, python weather.py Zzzzz et python weather.py Nice sans réseau donnent TOUS une sortie propre.
12. Correction / méthode de correction
Les solutions détaillées sont dans solutions/serie-09-3-solutions.md.
Méthode : (1) finis TOUS les exercices avant d'ouvrir les solutions ; (2) compare en particulier les choix défensifs : là où la solution met .get() et là où elle laisse un accès direct — le dosage est le vrai sujet ; (3) pour chaque KeyError/TypeError rencontré, note dans ton journal l'étage fautif et comment tu l'as identifié ; (4) refais de tête l'exercice du deep_get dans deux jours : c'est le meilleur révélateur de ta maîtrise des structures imbriquées.
13. À retenir
- JSON = dicts + listes + chaînes + nombres +
true/false/null— la traduction Python est quasi directe (True/False/None). - 4 fonctions :
load/dumppour les fichiers,loads/dumpspour les chaînes. Les= string. - JSON est du TEXTE : pas de crochets dessus avant
json.loads/response.json(). - Explorer un JSON inconnu : sonder étage par étage (
type(),.keys()), jamais deviner. - Listes parallèles reliées par l'indice → les assembler avec
zip. - Défense aux frontières :
.get(clé, défaut)pour les champs qui peuvent manquer ; accès direct (et crash bruyant) pour ceux qui sont garantis. - Écriture lisible :
ensure_ascii=False, indent=2+encoding="utf-8". JSONDecodeErrordonne ligne et colonne ;KeyError+^^^donne l'étage exact.
14. Questions de révision
- Quelles sont les correspondances JSON ↔ Python pour
true,null, et un objet{...}? - Quelle est la différence entre
json.dumpetjson.dumps? Comment t'en souviens-tu ? - Tu reçois
TypeError: string indices must be integersen lisant « du JSON » : qu'as-tu oublié ? - Décris la méthode pour explorer une réponse d'API de 200 lignes sans la lire en entier.
- Que sont des « listes parallèles » dans une réponse d'API, et quel outil Python les assemble ?
- Quand utiliser
data.get("clé", défaut)et quand PRÉFÉRERdata["clé"]? Pourquoi «.get()partout » est-il une erreur ? - À quoi servent
ensure_ascii=Falseetindent=2dansjson.dump? - Un
JSONDecodeError: Expecting value: line 1 column 1sur un fichier de cache : quelles sont les deux causes les plus probables ?
15. Checklist de compréhension
- Je convertis de tête entre JSON et Python (
true→True,null→None…). - Je choisis sans hésiter entre
load/dump/loads/dumps. - Je peux écrire le chemin
data[...][...][...]vers n'importe quelle valeur d'un JSON que je viens de sonder. - J'utilise
zippour parcourir des listes parallèles. - Je sais dire pour chaque clé d'une réponse d'API si elle mérite
.get()ou un accès direct — et justifier. - Mes fichiers JSON écrits sont lisibles (accents, indentation).
- J'ai terminé le mini-projet météo et il survit à ses trois pannes.
Si une case reste vide : refais les exercices de la section correspondante demain, AVANT d'ouvrir la leçon suivante.
16. Commit conseillé
git add lessons/level-09-automatisation-apis-donnees/exercices/mes-reponses/
git commit -m "exercises: complete lesson 09-3 JSON and public APIs"
Puis, si c'est ta fin de session : entrée de journal + git push.