feat: schéma résilient URL→cache + suppression DATA_SCHEMA_LOCAL (#78)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-06-12 13:26:03 +02:00
parent 21c65c34fb
commit 42ba73cd36
6 changed files with 603 additions and 29 deletions
+1
View File
@@ -11,3 +11,4 @@ build
**/decp.duckdb
**/decp.duckdb.tmp
**/decp.duckdb.lock
**/schema.cache.json
+1 -1
View File
@@ -9,7 +9,7 @@ ANNOUNCEMENTS=
# Chemin vers le schéma de données
DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a
DATA_SCHEMA_PATH_LOCAL=../schema.json
DATA_SCHEMA_CACHE=./schema.cache.json
# Colonnes masquées par défaut
DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset"
+50 -28
View File
@@ -2,7 +2,6 @@ import json
import logging
import os
from collections import OrderedDict
from pathlib import Path
import httpx
import polars as pl
@@ -65,34 +64,57 @@ def get_departement_region(code_postal: str | None):
return "", "", ""
def _validate_schema(raw) -> dict | None:
if isinstance(raw, dict) and isinstance(raw.get("fields"), list) and raw["fields"]:
return raw
return None
def _fetch_remote_schema(url: str | None) -> dict | None:
if not url:
return None
try:
raw = get(url, follow_redirects=True).raise_for_status().json()
except (httpx.HTTPError, json.JSONDecodeError) as e:
logger.error(f"Schéma distant indisponible ({url}) : {e}")
return None
return _validate_schema(raw)
def _load_schema_file(path: str) -> dict | None:
if not path or not os.path.exists(path):
return None
try:
with open(path) as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.error(f"Schéma local illisible ({path}) : {e}")
return None
return _validate_schema(raw)
def _persist_schema_cache(raw: dict, path: str) -> None:
if not path:
return
try:
tmp = f"{path}.tmp"
with open(tmp, "w") as f:
json.dump(raw, f)
os.replace(tmp, path)
except OSError as e:
logger.warning(f"Écriture du cache schéma échouée ({path}) : {e}")
def get_data_schema() -> dict:
# Récupération du schéma des données tabulaires
url = os.getenv("DATA_SCHEMA_PATH")
local_path = Path(os.getenv("DATA_SCHEMA_LOCAL", ""))
original_schema = {}
if url:
try:
original_schema: dict = get(url, follow_redirects=True).json()
except (
httpx.ReadTimeout,
httpx.ReadError,
httpx.ConnectError,
httpx.ConnectTimeout,
):
logger.error(f"Erreur HTTP lors de la récupération du schéma ({url})")
if os.path.exists(local_path) and original_schema == {}:
with open(local_path) as f:
original_schema: dict = json.load(f)
logger.info(f"Utilisation du schéma local ({local_path})")
new_schema = OrderedDict()
for col in original_schema["fields"]:
new_schema[col["name"]] = col
return new_schema
cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")
raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH"))
if raw is not None:
_persist_schema_cache(raw, cache_path)
else:
raw = _load_schema_file(cache_path)
if raw is None:
raise RuntimeError("Aucun schéma disponible (ni distant ni cache).")
return OrderedDict((c["name"], c) for c in raw["fields"])
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
+6
View File
@@ -42,6 +42,12 @@ _TEST_DATA = [
_PARQUET_PATH = Path(os.path.abspath("tests/test.parquet"))
_DB_PATH = Path(os.path.abspath("decp.duckdb"))
# Schéma déterministe et hors-ligne pour les tests : on pointe le cache sur un
# fixture commité et on désactive la récupération distante.
_SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json"))
os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE)
os.environ.pop("DATA_SCHEMA_PATH", None)
def _cleanup_db_artifacts() -> None:
for artifact in (
+468
View File
@@ -0,0 +1,468 @@
{
"fields": [
{
"name": "acheteur_categorie",
"type": "string",
"title": "Catégorie de l'acheteur",
"description": "Catégorie de l'acheteur selon son code juridique INSEE.",
"short_title": "Catégorie acheteur",
"enum": [
"Commune",
"Groupement de communes",
"Département",
"Département outre-mer",
"Région",
"État",
"Établissement hospitalier",
"EPIC",
"Syndicat mixte"
]
},
{
"name": "acheteur_commune_code",
"type": "string",
"title": "Commune de l'acheteur (code)",
"description": "Code de la commune où se trouve l'acheteur.",
"short_title": "Commune ach. (code)"
},
{
"name": "acheteur_commune_nom",
"type": "string",
"title": "Commune de l'acheteur",
"description": "Nom de la commune où se trouve l'acheteur.",
"short_title": "Commune acheteur"
},
{
"name": "acheteur_departement_code",
"type": "string",
"title": "Département de l'acheteur (code)",
"description": "Code du département où se trouve l'acheteur.",
"short_title": "Département ach. (code)"
},
{
"name": "acheteur_departement_nom",
"type": "string",
"title": "Département de l'acheteur",
"description": "Nom du département où se trouve l'acheteur.",
"short_title": "Département acheteur"
},
{
"name": "acheteur_id",
"type": "integer",
"title": "SIRET acheteur",
"description": "Identifiant de l'établissement de l'acheteur (SIRET), référencé dans la base SIRENE de l'INSEE.",
"short_title": null
},
{
"name": "acheteur_latitude",
"type": "number",
"title": "Latitude de l'acheteur",
"description": "Latitude des coordonnées géographiques de l'acheteur.",
"short_title": "Latitude acheteur"
},
{
"name": "acheteur_longitude",
"type": "number",
"title": "Longitude de l'acheteur",
"description": "Longitude des coordonnées géographiques de l'acheteur.",
"short_title": "Longitude acheteur"
},
{
"name": "acheteur_nom",
"type": "string",
"title": "Nom acheteur",
"description": "Nom de l'acheteur tel que renseigné dans la base SIRENE de l'INSEE.",
"short_title": "Acheteur"
},
{
"name": "acheteur_region_code",
"type": "string",
"title": "Région de l'acheteur (code)",
"description": "Code de la région où se trouve l'acheteur.",
"short_title": "Région ach. (code)"
},
{
"name": "acheteur_region_nom",
"type": "string",
"title": "Région de l'acheteur",
"description": "Nom de la région où se trouve l'acheteur.",
"short_title": "Région acheteur"
},
{
"name": "attributionAvance",
"type": "boolean",
"title": "Attribution avance",
"description": "Si une avance sur le montant du marché public a été attribuée aux titulaires.",
"short_title": null
},
{
"name": "ccag",
"type": "string",
"title": "CCAG",
"description": "Cahier des clauses administratives générales et techniques (CCAG) utilisé pour le marché public.",
"short_title": null,
"enum": [
"Travaux",
"Maitrise d'œuvre",
"Fournitures courantes et services",
"Marchés industriels",
"Prestations intellectuelles",
"Techniques de l'information et de la communication"
]
},
{
"name": "codeCPV",
"type": "string",
"title": "Code CPV",
"description": "Catégorie de bien, service ou travaux achetés, selon le Vocabulaire commun pour les marchés publics (CPV).",
"short_title": "CPV"
},
{
"name": "considerationsEnvironnementales",
"type": "string",
"title": "Considérations environnementales",
"description": "Les considérations environnementales prévues dans le marché public.",
"short_title": "Cons. environnementales",
"enum": ["Clause environnementale", "Critère environnemental"]
},
{
"name": "considerationsSociales",
"type": "string",
"title": "Considérations sociales",
"description": "Les considérations sociales prévues dans le marché public.",
"short_title": "Cons. sociales",
"enum": ["Clause sociale", "Critère social", "Marché réservé"]
},
{
"name": "dateNotification",
"type": "date",
"title": "Date notification",
"description": "Date à laquelle le marché public ou de la modification a été notifiée aux titulaires du marché public.",
"short_title": null,
"format": "default"
},
{
"name": "datePublicationDonnees",
"type": "date",
"title": "Date publication données",
"description": "Date à laquelle les données du marché public ou de la modification ont été publiées sur data.gouv.fr.",
"short_title": "Date pub. données",
"format": "default"
},
{
"name": "donneesActuelles",
"type": "boolean",
"title": "Données actuelles",
"description": "Si les données de cette ligne sont les données actuelles du marché public, une fois les éventuelles modifications prises en compte.",
"short_title": null
},
{
"name": "dureeMois",
"type": "integer",
"title": "Durée (mois)",
"description": "Durée en mois du marché attribué.",
"short_title": null
},
{
"name": "dureeRestanteMois",
"type": "number",
"title": "Durée restante (mois)",
"description": "Durée approximative en mois restante dans le marché, en tenant compte de la date de notification et de la durée du marché. Ce nombre ne peut être inférieur à 0.",
"short_title": null
},
{
"name": "formePrix",
"type": "string",
"title": "Forme prix",
"description": "La forme du prix du marché public. Unitaire, Forfaitaire ou Mixte.",
"short_title": null
},
{
"name": "id",
"type": "string",
"title": "Identifiant interne",
"description": "Identifiant attribué par l'acheteur, censé être unique au sein de ses marchés.",
"short_title": "Id. interne"
},
{
"name": "idAccordCadre",
"type": "string",
"title": "Identifiant accord-cadre",
"description": "Pour un marché subséquent, l'identifiant interne du marché public relevant de la technique d'achat accord-cadre auquel il est lié.",
"short_title": "Id. accord-cadre"
},
{
"name": "lieuExecution_code",
"type": "integer",
"title": "Code lieu exécution",
"description": "Code du lieu d'exécution du marché public. Le type de code est renseigné par 'Type code lieu exécution'.",
"short_title": "Lieu exécution"
},
{
"name": "lieuExecution_typeCode",
"type": "string",
"title": "Type code lieu exécution",
"description": "Type du code du lieu d'exécution.",
"short_title": "Type lieu exécution",
"enum": [
"Code postal",
"Code commune",
"Code arrondissement",
" Code canton",
"Code département",
"Code région",
"Code pays"
]
},
{
"name": "marcheInnovant",
"type": "boolean",
"title": "Marché innovant",
"description": "Si le marché comporte des travaux, services ou fournitures innovantes.",
"short_title": null
},
{
"name": "modalitesExecution",
"type": "string",
"title": "Modalités exécution",
"description": "Les modalités d'exécution du marché public.",
"short_title": null,
"enum": ["Tranches", "Bons de commande", "Marchés subséquents"]
},
{
"name": "modification_id",
"type": "integer",
"title": "Identifiant modification",
"description": "Identifiant de la modification. 0 = données initiales du marché public, 1 = première modification, etc.",
"short_title": "Id. modification"
},
{
"name": "montant",
"type": "number",
"title": "Montant attribué",
"description": "Montant forfaitaire ou montant maximum estimé hors-taxes, en euros. Ce montant est le montant attribué. Le montant final payé aux titulaires peut évoluer lors de la signature du contrat et de l'exécution du marché.",
"short_title": "Montant"
},
{
"name": "nature",
"type": "string",
"title": "Nature",
"description": "Marché, Marché de partenariat ou Marché de sécurité.",
"short_title": null
},
{
"name": "objet",
"type": "string",
"title": "Objet",
"description": "Objet du marché public. Potentiellement coupé à 256 ou 1 000 caractères par le producteur de données.",
"short_title": null
},
{
"name": "offresRecues",
"type": "integer",
"title": "Offres reçues",
"description": "Le nombre d'offres reçues pendant la phase d'appel d'offres. Comprend aussi les offres irrégulières, inacceptables, inappropriées et anormalement basses.",
"short_title": null
},
{
"name": "origineFrance",
"type": "number",
"title": "Origine France",
"description": "Pour les marchés de fournitures de denrées alimentaires, de véhicules, de produits de santé et d'habillement, selon la liste annexée à l'arrêté du 22 décembre 2022, la part des produits français avec laquelle le marché sera exécuté. 0.2 = 20 % de la part des produits sont français. Cette valeur ne peut pas être supérieure à la valeur de origineUE.",
"short_title": null
},
{
"name": "origineUE",
"type": "number",
"title": "Origine UE",
"description": "Pour les marchés de fournitures de denrées alimentaires, de véhicules, de produits de santé et d'habillement, selon la liste annexée à l'arrêté du 22 décembre 2022, la part des produits issus de l'Union européenne avec laquelle le marché sera exécuté. 0.2 = 20 % de la part des produits provient de l'Union européenne. Cette valeur ne peut pas être inférieure à la valeur de origineFrance.",
"short_title": null
},
{
"name": "procedure",
"type": "string",
"title": "Procédure",
"description": "Le type de procédure utilisé pour le marché public.",
"short_title": null,
"enum": [
"Procédure négociée ouverte",
"Procédure non négociée ouverte",
"Procédure négociée restreinte",
"Procédure non négociée restreinte"
]
},
{
"name": "sourceDataset",
"type": "string",
"title": "Source dataset",
"description": "Code du jeu de données dont proviennent les données de ce marché public.",
"short_title": null
},
{
"name": "sourceFile",
"type": "string",
"title": "Source fichier",
"description": "Lien vers le fichier de données ouvertes dont proviennent les données de ce marché public.",
"short_title": null,
"format": "uri"
},
{
"name": "sousTraitanceDeclaree",
"type": "boolean",
"title": "Sous-traitance déclarée",
"description": "Au moment de la notification du marché, les titulaires du marché ont déclaré s'appuyer sur un ou plusieurs sous-traitants pour ce marché public.",
"short_title": "Sous-traitance"
},
{
"name": "tauxAvance",
"type": "number",
"title": "Taux avance",
"description": "Taux de l'avance attribuée au titulaire principal du marché public par rapport au montant du marché (O.1 = 10 % du montant du marché). En fonction de la valeur de attributionAvance, une valeur égale à 0 signifie qu'il y a une avance mais que le taux n'est pas connu (attributionAvance=true).",
"short_title": null
},
{
"name": "techniques",
"type": "string",
"title": "Techniques",
"description": "Les techniques d'achat utilisées pour le marché public.",
"short_title": null,
"enum": [
"Accord-cadre",
"Concours",
"Système de qualification",
"Système d'acquisition dynamique",
"Catalogue électronique",
"Enchère électronique"
]
},
{
"name": "titulaire_categorie",
"type": "string",
"title": "Catégorie du titulaire",
"description": "Catégorie de l'entreprise titulaire selon la classification de l'INSEE.",
"short_title": "Catégorie titulaire",
"enum": ["PME", "ETI", "GE"]
},
{
"name": "titulaire_commune_code",
"type": "string",
"title": "Commune du titulaire (code)",
"description": "Code de la commune où se trouve le titulaire.",
"short_title": "Commune tit. (code)"
},
{
"name": "titulaire_commune_nom",
"type": "string",
"title": "Commune du titulaire",
"description": "Nom de la commune où se trouve le titulaire.",
"short_title": "Commune titulaire"
},
{
"name": "titulaire_departement_code",
"type": "string",
"title": "Département du titulaire (code)",
"description": "Code du département où se trouve le titulaire.",
"short_title": "Département tit. (code)"
},
{
"name": "titulaire_departement_nom",
"type": "string",
"title": "Département du titulaire",
"description": "Nom du département où se trouve le titulaire.",
"short_title": "Département titulaire"
},
{
"name": "titulaire_distance",
"type": "integer",
"title": "Distance acheteur-titulaire",
"description": "Distance en kilomètres entre l'adresse de l'acheteur et celle du titulaire.",
"short_title": "Distance"
},
{
"name": "titulaire_id",
"type": "integer",
"title": "Identifiant titulaire",
"description": "Identifiant du titulaire du marché. Voir 'Type identifiant' pour le référentiel utilisé",
"short_title": "Id. titulaire"
},
{
"name": "titulaire_latitude",
"type": "number",
"title": "Latitude du titulaire",
"description": "Latitude des coordonnées géographiques du titulaire.",
"short_title": "Latitude titulaire"
},
{
"name": "titulaire_longitude",
"type": "number",
"title": "Longitude du titulaire",
"description": "Longitude des coordonnées géographiques du titulaire.",
"short_title": "Longitude titulaire"
},
{
"name": "titulaire_nom",
"type": "string",
"title": "Nom titulaire",
"description": "Nom du titulaire. Nom tel que renseigné dans la base SIRENE de l'INSEE si c'est un SIRET.",
"short_title": "Titulaire"
},
{
"name": "titulaire_region_code",
"type": "string",
"title": "Région du titulaire (code)",
"description": "Code de la région où se trouve le titulaire.",
"short_title": "Région tit. (code)"
},
{
"name": "titulaire_region_nom",
"type": "string",
"title": "Région du titulaire",
"description": "Nom de la région où se trouve le titulaire.",
"short_title": "Région titulaire"
},
{
"name": "titulaire_typeIdentifiant",
"type": "string",
"title": "Type identifiant",
"description": "Référentiel utilisé pour l'identifiant du titulaire.",
"short_title": "Type id.",
"enum": ["SIRET", "TVA", "TAHITI", "RIDET", "FRWF", "IREP", "HORS-UE"]
},
{
"name": "type",
"type": "string",
"title": "Type",
"description": "Type de marché public : fournitures, services ou travaux (dérivé du code CPV).",
"short_title": "Type",
"enum": ["Fournitures", "Services", "Travaux"]
},
{
"name": "typeGroupementOperateurs",
"type": "string",
"title": "Type groupement",
"description": "Le type de groupement d'entreprises ou d'opérateurs économiques.",
"short_title": "Groupement",
"enum": ["Conjoint", "Solidaire"]
},
{
"name": "typesPrix",
"type": "string",
"title": "Types prix",
"description": "Les types de prix du marché public.",
"short_title": null,
"enum": [
"Définitif ferme",
"Définitif actualisable",
"Définitif révisable",
"Provisoire"
]
},
{
"name": "uid",
"type": "string",
"title": "Identifiant unique",
"description": "Concaténation du SIRET de l'acheteur (acheteur_id) et de l'identifiant interne de l'acheteur (id). Utilisé comme identifiant de marché unique au niveau national.",
"short_title": "Id. unique"
}
]
}
+77
View File
@@ -0,0 +1,77 @@
import json
import httpx
import pytest
from src.utils import data as data_mod
VALID = {"fields": [{"name": "uid", "title": "UID"}, {"name": "objet"}]}
class FakeResp:
def __init__(self, payload, ok=True, bad_json=False):
self._payload = payload
self._ok = ok
self._bad_json = bad_json
def raise_for_status(self):
if not self._ok:
raise httpx.HTTPError("boom")
return self
def json(self):
if self._bad_json:
raise json.JSONDecodeError("bad", "", 0)
return self._payload
def test_remote_ok_returns_schema_and_writes_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
result = data_mod.get_data_schema()
assert "uid" in result
assert json.loads(cache.read_text())["fields"][0]["name"] == "uid"
def test_remote_http_error_falls_back_to_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
cache.write_text(json.dumps(VALID))
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(None, ok=False))
assert "uid" in data_mod.get_data_schema()
def test_remote_malformed_falls_back_to_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
cache.write_text(json.dumps(VALID))
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp({"nope": 1}))
assert "uid" in data_mod.get_data_schema()
def test_no_url_uses_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
cache.write_text(json.dumps(VALID))
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
assert "uid" in data_mod.get_data_schema()
def test_no_source_raises(tmp_path, monkeypatch):
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(tmp_path / "missing.json"))
with pytest.raises(RuntimeError):
data_mod.get_data_schema()
def test_cache_write_failure_is_non_blocking(tmp_path, monkeypatch):
# parent inexistant => l'écriture du cache échoue, mais le schéma est renvoyé
cache = tmp_path / "nodir" / "schema.cache.json"
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
assert "uid" in data_mod.get_data_schema()