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
**/decp.duckdb.tmp **/decp.duckdb.tmp
**/decp.duckdb.lock **/decp.duckdb.lock
**/schema.cache.json
+1 -1
View File
@@ -9,7 +9,7 @@ ANNOUNCEMENTS=
# Chemin vers le schéma de données # 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=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 # 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" 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 logging
import os import os
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path
import httpx import httpx
import polars as pl import polars as pl
@@ -65,34 +64,57 @@ def get_departement_region(code_postal: str | None):
return "", "", "" 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: def get_data_schema() -> dict:
# Récupération du schéma des données tabulaires cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")
url = os.getenv("DATA_SCHEMA_PATH") raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH"))
local_path = Path(os.getenv("DATA_SCHEMA_LOCAL", "")) if raw is not None:
_persist_schema_cache(raw, cache_path)
original_schema = {} else:
if url: raw = _load_schema_file(cache_path)
try: if raw is None:
original_schema: dict = get(url, follow_redirects=True).json() raise RuntimeError("Aucun schéma disponible (ni distant ni cache).")
except ( return OrderedDict((c["name"], c) for c in raw["fields"])
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
def prepare_dashboard_data(**filter_params) -> pl.DataFrame: 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")) _PARQUET_PATH = Path(os.path.abspath("tests/test.parquet"))
_DB_PATH = Path(os.path.abspath("decp.duckdb")) _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: def _cleanup_db_artifacts() -> None:
for artifact in ( 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()