From 42ba73cd366e4826bd6792a8c38144021f5bad95 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:26:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20sch=C3=A9ma=20r=C3=A9silient=20URL?= =?UTF-8?q?=E2=86=92cache=20+=20suppression=20DATA=5FSCHEMA=5FLOCAL=20(#78?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + .template.env | 2 +- src/utils/data.py | 78 ++++--- tests/conftest.py | 6 + tests/schema.fixture.json | 468 ++++++++++++++++++++++++++++++++++++++ tests/test_schema.py | 77 +++++++ 6 files changed, 603 insertions(+), 29 deletions(-) create mode 100644 tests/schema.fixture.json create mode 100644 tests/test_schema.py diff --git a/.gitignore b/.gitignore index 6c17783..2bff50a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ build **/decp.duckdb **/decp.duckdb.tmp **/decp.duckdb.lock +**/schema.cache.json diff --git a/.template.env b/.template.env index cb227e4..6b9744a 100644 --- a/.template.env +++ b/.template.env @@ -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" diff --git a/src/utils/data.py b/src/utils/data.py index c5304d5..339f826 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -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: diff --git a/tests/conftest.py b/tests/conftest.py index f8d702f..9ef9b6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 ( diff --git a/tests/schema.fixture.json b/tests/schema.fixture.json new file mode 100644 index 0000000..fc10e53 --- /dev/null +++ b/tests/schema.fixture.json @@ -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" + } + ] +} diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..75a214a --- /dev/null +++ b/tests/test_schema.py @@ -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()