Solutions — série 09-3 (JSON et APIs publiques)
Rappel de la règle d'or : on n'ouvre ce fichier qu'après avoir terminé (ou sérieusement séché sur) la série. Compare le raisonnement, pas seulement le code : une solution différente qui marche est souvent valable.
Exercice facile a — parser et inspecter une chaîne JSON
Raisonnement — Un seul geste nouveau (json.loads), puis du pur niveau 03 : accès par clé, len, indexation. L'intérêt est dans la prédiction des types : que deviennent true et null en Python ?
Solution
# ex-09-3-a.py
import json
raw = '{"name": "Ada", "languages": ["python", "sql"], "active": true, "score": null}'
data = json.loads(raw)
print(data["name"]) # Ada
print(len(data["languages"])) # 2
print(data["languages"][0]) # python
print(type(data["active"])) # <class 'bool'>
print(type(data["score"])) # <class 'NoneType'>
Pourquoi ça marche — json.loads traduit chaque type JSON vers son équivalent Python : true → True (bool), null → None (NoneType), le tableau → une vraie list indexable. Après la ligne 4, il n'y a plus de JSON dans ce programme — que des objets Python ordinaires.
Erreur classique sur cet exercice — Prédire <class 'str'> pour active (« tout est texte dans du JSON ») : non — le JSON est TRANSPORTÉ en texte, mais il TYPE ses valeurs, et loads restitue ces types. La confusion inverse existe aussi : croire que data["score"] va planter parce que null serait « rien » ; c'est une valeur parfaitement normale, None.
Variante plus difficile — Ajoute une clé imbriquée "contact": {"email": null, "city": "Nice"} dans la chaîne et affiche la ville. Puis casse volontairement le JSON (retire une accolade) et lis le JSONDecodeError : position exacte, colonne exacte.
Exercice facile b — profile.json, aller-retour sans perte
Raisonnement — Le duo dump/load avec les options de lisibilité. Le test loaded == profile est le contrat : si l'aller-retour n'est pas identique, quelque chose s'est perdu en route.
Solution
# ex-09-3-b.py
import json
profile = {
"name": "Jean",
"city": "Nice",
"goals": ["autonomie Python", "automatiser mes rapports"],
"progress": {"level-07": "done", "level-08": "done", "level-09": "in progress"},
}
with open("profile.json", "w", encoding="utf-8") as f:
json.dump(profile, f, ensure_ascii=False, indent=2)
with open("profile.json", encoding="utf-8") as f:
loaded = json.load(f)
print(loaded == profile) # True
Contenu de profile.json (extrait — indenté, accents intacts) :
{
"name": "Jean",
"city": "Nice",
"goals": [
"autonomie Python",
"automatiser mes rapports"
],
...
}
Pourquoi ça marche — dump sérialise le dict (avec indent=2 pour la lisibilité et ensure_ascii=False pour garder les accents lisibles) ; load reconstruit des objets Python équivalents. L'égalité == compare en profondeur (dicts imbriqués, listes) — un True ici prouve l'aller-retour complet.
Erreur classique sur cet exercice — Ouvrir en "w" pour RELIRE le fichier : le mode "w" TRONQUE à l'ouverture — le fichier est vidé avant même la lecture, et json.load plante sur Expecting value: line 1 column 1 (char 0). Lecture = pas de mode (ou "r"). Ce traceback sur un fichier qu'on « vient d'écrire » = presque toujours cette erreur-là.
Variante plus difficile — Modifie loaded (ajoute un objectif), réécris le fichier, recharge et vérifie. Tu tiens le cycle lire-modifier-réécrire : c'est exactement ainsi qu'un programme entretient un petit fichier d'état (paramètres, scores, todo…) entre deux lancements.
Exercice facile c — sonder une vraie réponse d'API
Raisonnement — Appliquer la méthode de sondage étage par étage, SANS jamais afficher le JSON complet. Le livrable : être capable d'ÉCRIRE le chemin d'accès après le sondage, et le vérifier.
Solution
# ex-09-3-c.py
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(type(data)) # <class 'dict'>
print(sorted(data.keys())) # ['daily', 'daily_units', 'elevation', ...]
print(type(data["daily"])) # <class 'dict'>
print(sorted(data["daily"].keys())) # ['temperature_2m_max', 'temperature_2m_min', 'time']
print(type(data["daily"]["time"])) # <class 'list'>
print(len(data["daily"]["time"])) # 3
# chemin vers la température maximale du 2e jour :
# data (dict) → "daily" (dict) → "temperature_2m_max" (liste) → [1] (2e jour)
print(data["daily"]["temperature_2m_max"][1]) # ex. 28.2
Pourquoi ça marche — Chaque type() / .keys() / len() révèle UN étage : dict → accès par clé, liste → accès par indice. Le chemin final n'est pas deviné, il est DÉDUIT du sondage — et l'indice [1] (2e jour) découle du fait que time[0] est aujourd'hui.
Erreur classique sur cet exercice — print(data) « pour voir » : 30 lignes compactes illisibles, et on n'a rien appris de la STRUCTURE. Le sondage est plus lent la première fois, mais il apprend la carte du territoire — et sur une API de 200 lignes, c'est la seule méthode qui tient.
Variante plus difficile — Écris probe(obj, depth=0) : une fonction récursive (⏩ récursivité effleurée au niveau 04) qui affiche la structure d'un JSON — clés et types pour un dict, longueur et type du premier élément pour une liste — avec une indentation par profondeur. Ton radiographe de JSON personnel.
Exercice moyen a — summarize_forecast : zip et amplitude
Raisonnement — Trois listes parallèles à assembler (time, min, max), un calcul par ligne (l'amplitude), et un agrégat final (le jour le plus chaud). zip transforme le problème d'indices en boucle lisible ; le max final se calcule sur des paires (tmax, date).
Solution
# ex-09-3-ma.py
import requests
def summarize_forecast(data):
"""Print one line per day: date, min-max range, amplitude."""
daily = data["daily"]
hottest_temp = None
hottest_date = None
for date, tmin, tmax in zip(
daily["time"],
daily["temperature_2m_min"],
daily["temperature_2m_max"],
):
amplitude = round(tmax - tmin, 1)
print(f"{date} : {tmin} à {tmax} °C (amplitude {amplitude})")
if hottest_temp is None or tmax > hottest_temp:
hottest_temp = tmax
hottest_date = date
print(f"Journée la plus chaude : {hottest_date} ({hottest_temp} °C)")
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": 5, "timezone": "auto"},
timeout=10,
)
response.raise_for_status()
summarize_forecast(response.json())
Sortie type :
2026-07-05 : 22.6 à 28.9 °C (amplitude 6.3)
2026-07-06 : 21.8 à 28.2 °C (amplitude 6.4)
2026-07-07 : 22.1 à 27.5 °C (amplitude 5.4)
2026-07-08 : 21.5 à 27.9 °C (amplitude 6.4)
2026-07-09 : 22.4 à 29.4 °C (amplitude 7.0)
Journée la plus chaude : 2026-07-09 (29.4 °C)
Pourquoi ça marche — zip sert chaque jour complet (date, min, max) en un tour de boucle : l'indice qui reliait les listes a disparu du code. Le suivi du maximum au fil de la boucle (motif « champion » du niveau 02) évite un second parcours. round(..., 1) protège des 6.400000000000002 de l'arithmétique flottante (⏩ niveau 01).
Erreur classique sur cet exercice — La boucle par indices for i in range(len(daily["time"])): avec trois daily[...][i] : ça marche, mais c'est illisible et fragile (un i de trop quelque part). Si ta solution est ainsi, réécris-la avec zip — c'est le geste que l'exercice entraîne. Autre piège : chercher le jour le plus chaud avec max(daily["temperature_2m_max"]) puis retrouver la date avec .index() — correct, mais deux parcours et un écueil si deux jours ont le même max.
Variante plus difficile — Ajoute precipitation_sum aux paramètres daily et enrichis chaque ligne d'un marqueur pluie si la somme dépasse 1 mm. Quatre listes dans le zip — la lisibilité de zip se paie à l'écriture de la ligne for : discute (dans ton journal) à partir de combien de listes il vaudrait mieux construire une liste de dicts.
Exercice moyen b — find_city multi-résultats, défensive
Raisonnement — Passage de « premier résultat » à « liste de résultats » : la fonction renvoie une liste (éventuellement vide — le cas « ville inconnue » devient naturel : liste vide, boucle à zéro tour). country est le seul champ traité en optionnel, conformément à la doc.
Solution
# ex-09-3-mb.py
import requests
def find_city(name, count=5):
"""Return a list of (name, country, lat, lon) matches; [] if none."""
try:
response = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": name, "count": count, "language": "fr"},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.RequestException as error:
print(f"Échec réseau/HTTP : {error}")
return []
matches = []
for city in response.json().get("results", []): # clé absente → []
matches.append((
city["name"],
city.get("country", "?"), # champ optionnel
city["latitude"],
city["longitude"],
))
return matches
for i, (name, country, lat, lon) in enumerate(find_city("Nice"), start=1):
print(f"{i}. {name} ({country}) — {lat}, {lon}")
print(find_city("Zzzzzz")) # []
Sortie type (oui, il y a plusieurs Nice) :
1. Nice (France) — 43.70313, 7.26608
2. Niceville (États-Unis) — 30.51686, -86.48217
...
[]
Pourquoi ça marche — [] est LA valeur d'échec idéale pour une fonction qui renvoie une liste : l'appelant peut boucler dessus sans test préalable (zéro tour), et if not matches: reste possible quand il veut distinguer. Le dosage défensif suit la règle de la leçon : .get pour results et country (absences documentées ou plausibles), accès directs pour name/latitude/longitude (garantis — leur absence DOIT planter).
Erreur classique sur cet exercice — Renvoyer None pour « aucun résultat » ET [] pour « erreur réseau » (ou l'inverse) : l'appelant doit alors gérer deux valeurs d'échec différentes, et un for city in None plante (TypeError: 'NoneType' object is not iterable). Une seule convention : liste, toujours — vide en cas de pépin.
Variante plus difficile — Renvoie des dicts plutôt que des tuples ({"name": ..., "country": ..., ...}) et ajoute population (optionnel, défaut 0). Trie par population décroissante avant de renvoyer. Compare : qu'est-ce que le dict rend plus lisible chez l'appelant ? Qu'est-ce que le tuple garantissait que le dict ne garantit plus ?
Exercice moyen c — le cache horodaté
Raisonnement — Deux changements par rapport à l'exemple 6 : la structure du fichier devient une enveloppe {"cached_at": ..., "data": ...} (métadonnée + contenu, motif très courant), et il faut sérialiser un horodatage — or datetime n'est pas « JSON serializable » : on stocke sa forme texte ISO, reconvertible plus tard.
Solution
# ex-09-3-mc.py
import json
from datetime import datetime
from pathlib import Path
import requests
CACHE = Path("forecast_cache.json")
def get_forecast():
"""Fetch the forecast, caching it locally with a timestamp."""
if CACHE.exists():
with open(CACHE, encoding="utf-8") as f:
wrapper = json.load(f)
cached_at = datetime.fromisoformat(wrapper["cached_at"])
age = datetime.now() - cached_at
print(f"(cache utilisé, âge : {age.seconds // 60} min)")
return wrapper["data"]
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()
wrapper = {
"cached_at": datetime.now().isoformat(), # datetime → texte ISO
"data": data,
}
with open(CACHE, "w", encoding="utf-8") as f:
json.dump(wrapper, f, ensure_ascii=False, indent=2)
print("(appel réseau, cache écrit)")
return data
data = get_forecast()
print(data["daily"]["temperature_2m_max"])
Sortie (1er puis 2e lancement) :
(appel réseau, cache écrit)
[28.9, 28.2, 27.5]
---
(cache utilisé, âge : 0 min)
[28.9, 28.2, 27.5]
Pourquoi ça marche — datetime.now().isoformat() produit '2026-07-05T22:41:07.123456' — du texte, donc sérialisable ; et datetime.fromisoformat fait le chemin inverse à la lecture : le couple règle proprement le TypeError: Object of type datetime is not JSON serializable. L'enveloppe sépare métadonnées et données : wrapper["data"] reste EXACTEMENT la réponse de l'API, réutilisable telle quelle.
Erreur classique sur cet exercice — Mettre cached_at DANS le dict de données de l'API (data["cached_at"] = ...) : ça marche… jusqu'au jour où un code croit que ce champ vient d'Open-Meteo, ou qu'une clé du même nom apparaît dans l'API. On ne mélange pas ses annotations avec les données des autres — d'où l'enveloppe. Autre classique : str(datetime.now()) fonctionne aussi pour écrire, mais fromisoformat est le décodeur assorti à isoformat() — utilise la paire officielle.
Variante plus difficile — Ajoute une durée de validité : si le cache a plus de max_age_minutes (paramètre, défaut 60), on rappelle l'API et on réécrit. C'est le comportement des vrais caches HTTP — ton navigateur fait ça des milliers de fois par jour.
Exercice difficile a — deep_get
Raisonnement — Une descente itérative : on part de l'objet racine et, pour chaque segment du chemin, on avance d'UN étage — en adaptant le geste au type de l'étage courant (clé de dict, indice de liste). Tout écart au plan (clé absente, indice hors bornes, segment non numérique sur une liste, étage qui n'est ni dict ni liste) → default, immédiatement. Jamais d'exception : c'est le contrat.
Solution
# ex-09-3-da.py
def deep_get(data, path, default=None):
"""Walk `path` ("a.b.0.c") through nested dicts/lists; never raise."""
current = data
for segment in path.split("."):
if isinstance(current, dict):
if segment not in current:
return default
current = current[segment]
elif isinstance(current, list):
if not segment.isdigit() or int(segment) >= len(current):
return default
current = current[int(segment)]
else: # ni dict ni liste : on ne peut pas descendre
return default
return current
# tests sur une structure type Open-Meteo
data = {
"daily": {
"time": ["2026-07-05", "2026-07-06", "2026-07-07"],
"temperature_2m_max": [28.9, 28.2, 27.5],
}
}
print(deep_get(data, "daily.time.1")) # 2026-07-06
print(deep_get(data, "daily.humidity.0", "n/a")) # n/a (clé absente)
print(deep_get(data, "results.0.name", "?")) # ? (étage racine absent)
print(deep_get(data, "daily.time.9", "hors bornes")) # hors bornes
print(deep_get(data, "daily.time.0.x", "stop")) # stop (on ne descend pas dans une str)
Pourquoi ça marche — isinstance (⏩ croisé au niveau 06) aiguille le geste selon le type réel de l'étage courant — exactement ce que tu fais de tête quand tu écris data["daily"]["time"][1], mais avec les six sorties de secours codées. Le return default précoce évite tout état incohérent : dès qu'un étage manque, on sait que la suite du chemin est morte.
Erreur classique sur cet exercice — La version « optimiste emballée » :
def deep_get(data, path, default=None):
try:
for segment in path.split("."):
data = data[segment] # et les indices de liste ?!
return data
except (KeyError, IndexError, TypeError):
return default
Elle échoue sur les listes (data["0"] au lieu de data[0] → attrapé par le except, donc default renvoyé À TORT pour un chemin valide). Le try/except large ne remplace pas l'analyse par type — il masque justement le cas qui demandait de la finesse.
Variante plus difficile — Accepte aussi les indices négatifs ("daily.time.-1" → dernier élément) et un séparateur paramétrable (sep="/"). Puis écris 5 tests pytest (⏩ niveau 08) couvrant : chemin valide, clé absente, indice hors bornes, indice négatif, descente dans un non-conteneur. deep_get est typiquement une fonction de bibliothèque personnelle : elle mérite ses tests.
Exercice difficile b — comparer deux villes → comparison.json
Raisonnement — Un pipeline complet : géocoder deux noms, chercher leurs prévisions, TRANSFORMER les listes parallèles en liste de dicts (plus lisible pour le fichier final), et tout écrire dans un JSON propre. Chaque étape réutilise une brique déjà écrite dans la série — c'est un exercice d'assemblage et de forme de sortie.
Solution
# ex-09-3-db.py
import json
import sys
import time
import requests
def geocode(name):
"""Return {"name", "country", "lat", "lon"} or None."""
try:
response = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": name, "count": 1, "language": "fr"},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.RequestException as error:
print(f"Géocodage de « {name} » impossible : {error}")
return None
results = response.json().get("results", [])
if not results:
print(f"Ville inconnue : « {name} »")
return None
city = results[0]
return {"name": city["name"], "country": city.get("country", "?"),
"lat": city["latitude"], "lon": city["longitude"]}
def fetch_daily(lat, lon):
"""Return the 3-day forecast as a list of dicts, or None."""
try:
response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": lat, "longitude": lon,
"daily": "temperature_2m_min,temperature_2m_max",
"forecast_days": 3, "timezone": "auto"},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.RequestException as error:
print(f"Prévisions indisponibles : {error}")
return None
daily = response.json()["daily"]
return [ # parallèle → liste de dicts
{"date": date, "min": tmin, "max": tmax}
for date, tmin, tmax in zip(
daily["time"],
daily["temperature_2m_min"],
daily["temperature_2m_max"],
)
]
def compare(city_a, city_b):
comparison = {}
for i, name in enumerate([city_a, city_b]):
if i > 0:
time.sleep(1) # politesse entre villes
location = geocode(name)
if location is None:
continue # signalé par geocode, on passe
forecast = fetch_daily(location["lat"], location["lon"])
if forecast is None:
continue
comparison[location["name"]] = {
"country": location["country"],
"forecast": forecast,
}
if not comparison:
print("Aucune donnée à écrire.")
return
with open("comparison.json", "w", encoding="utf-8") as f:
json.dump(comparison, f, ensure_ascii=False, indent=2)
print(f"comparison.json écrit ({len(comparison)} ville(s)).")
if len(sys.argv) != 3: # ⏩ sys.argv : niveau 05
print("Usage : python ex-09-3-db.py VILLE_A VILLE_B")
else:
compare(sys.argv[1], sys.argv[2])
Extrait de comparison.json :
{
"Nice": {
"country": "France",
"forecast": [
{"date": "2026-07-05", "min": 22.6, "max": 28.9},
{"date": "2026-07-06", "min": 21.8, "max": 28.2},
{"date": "2026-07-07", "min": 22.1, "max": 27.5}
]
},
"Paris": { ... }
}
Pourquoi ça marche — La transformation en liste de dicts (dans fetch_daily, avec une compréhension sur zip) fait tout le confort du fichier final : un lecteur humain voit des JOURS, pas trois colonnes à recouper. Une ville en échec est simplement absente du résultat — signalée sur la sortie standard, sans faire échouer l'autre. Et le script écrit quand même si UNE seule ville a réussi : dégradation gracieuse.
Erreur classique sur cet exercice — Écrire les listes parallèles telles quelles dans le fichier (« l'API le fait bien, moi aussi ») : l'exercice demandait précisément la transformation — c'est elle qui prouve la maîtrise de zip + structures. Autre piège : comparison[name] avec le nom TAPÉ par l'utilisateur comme clé au lieu du nom RÉSOLU (location["name"]) — python ... nice paris produirait des clés en minuscules incohérentes avec le contenu.
Variante plus difficile — Ajoute une section "verdict" calculée : pour chaque date, quelle ville a le max le plus élevé, et un compte final ({"Nice": 2, "Paris": 1}). Il faut ALIGNER les deux prévisions par date — que se passe-t-il si les fuseaux donnent des dates différentes ? (Indice : construis un dict date → max par ville, et itère sur l'intersection des dates.)
Mini-projet — « météo du terminal », version finale
La fiche complète (cahier des charges, exemple de sortie, extensions) : projects/mini-projects/11-meteo-api.md.
Au moment de te corriger, vérifie ces points précis, dans cet ordre : (1) les trois pannes — réseau coupé, ville inconnue, erreur serveur — donnent trois messages distincts et zéro traceback ; (2) chaque requests.get a son timeout ; (3) les prévisions utilisent zip sur les listes parallèles ; (4) la traduction des weather_code passe par un dict avec .get(code, "conditions inconnues") — jamais une cascade de if sans défaut ; (5) la logique (fonctions qui RENVOIENT) est séparée de l'affichage (le print final). Si ces cinq points tiennent, ton programme est au niveau attendu — les différences de présentation avec la fiche sont du goût, pas des fautes.