From 8fb1a2084db3a3998a6d212ebd3d565c31a24c6f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 12:18:35 +0200 Subject: [PATCH 01/34] =?UTF-8?q?Spec=20:=20API=20priv=C3=A9e=20tabulaire?= =?UTF-8?q?=20avec=20tokens=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design retenu : blueprint Flask + flask-smorest sur /api/v1, endpoint tabulaire générique aligné sur le swagger data.gouv.fr, tokens Bearer admin manuels (CLI), suivi via Matomo async + compteurs SQLite légers. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-05-13-api-privee-design.md | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-api-privee-design.md diff --git a/docs/superpowers/specs/2026-05-13-api-privee-design.md b/docs/superpowers/specs/2026-05-13-api-privee-design.md new file mode 100644 index 0000000..0a838c3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-api-privee-design.md @@ -0,0 +1,428 @@ +# API privée decp.info — Design + +**Date** : 2026-05-13 +**Statut** : design validé, en attente du plan d'implémentation + +## 1. Contexte et objectifs + +decp.info reçoit des demandes récurrentes pour un accès programmatique aux +données DECP exposées par l'application web. Le besoin est d'ouvrir une API +HTTP **privée** (accès sur token), inspirée de l'API tabulaire de data.gouv.fr +(https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/swagger/), +qu'un utilisateur en cours s'est déjà appropriée comme référence. + +Objectifs explicites : + +- Réponses rapides. +- API documentée (OpenAPI + Swagger UI). +- Suivi de la consommation par utilisateur. + +Non-objectifs (V1) : + +- Self-service de création de tokens via UI web. +- Rate-limiting / quotas. +- Formats de sortie autres que JSON (CSV, Parquet…). +- Endpoints sémantiques métier (`/acheteurs/{id}`, etc.). + +## 2. Choix structurants + +### 2.1 Framework : Flask + flask-smorest + +L'API est ajoutée à l'application Flask existante (serveur Dash) sous forme +d'un blueprint flask-smorest monté sur `/api/v1`. Choix motivé par : + +- L'app Dash actuelle tourne déjà sur Flask via gunicorn. +- DuckDB est ouvert une seule fois au boot dans `src/db.py` (`conn` read-only) + et peut être partagé directement par les endpoints API. +- L'API est tabulaire avec filtres **dynamiques** : la liste des colonnes et + des types vient du schéma DuckDB, pas d'une déclaration Pydantic. Les + bénéfices de FastAPI (auto-validation Pydantic) sont donc faibles. +- flask-smorest génère OpenAPI + sert Swagger UI nativement. +- Un seul process, un seul serveur, un seul déploiement. + +Alternatives écartées : + +- **FastAPI séparé reverse-proxié** : deux processus, ops plus complexe, + bénéfice marginal vu les filtres dynamiques. +- **FastAPI englobant Flask via WSGIMiddleware** : changerait le serveur de + toute l'app Dash existante, migration risquée. + +### 2.2 Style d'API : tabulaire générique + +Un endpoint unique de requête (`/api/v1/data`) avec filtres dynamiques sur +toutes les colonnes du schéma, à l'image du swagger cible. Aucun endpoint +sémantique métier en V1. + +### 2.3 Authentification : tokens admin manuels + +Tokens Bearer émis manuellement par l'admin via un CLI. Pas de page web de +gestion en V1. Modèle prévu pour se lier ultérieurement aux comptes +utilisateurs (cf. `comptes_utilisateurs.md`) sans migration de données. + +### 2.4 Suivi de consommation : Matomo asynchrone + compteurs locaux + +- Matomo en fire-and-forget pour l'analyse fine (qui, quand, quoi, code HTTP). +- Compteurs locaux SQLite (`count_total`, `last_used_at`) pour identifier + les tokens inactifs et préparer un éventuel rate-limit futur. + +## 3. Architecture + +### 3.1 Arborescence + +``` +src/api/ +├── __init__.py # init_api(server) — enregistre le blueprint flask-smorest +├── routes.py # endpoints /data, /schema, /health +├── schemas.py # marshmallow : query params, réponses +├── filters.py # parsing & validation `col__op=val` → (where_sql, params) +├── auth.py # décorateur @require_token, header Authorization Bearer +├── tracking.py # worker thread compteurs SQLite + httpx fire-and-forget Matomo +├── tokens_db.py # CRUD api_tokens dans users.sqlite +└── tokens_cli.py # python -m src.api.tokens_cli create|list|revoke +``` + +`src/auth/` reste réservé aux comptes utilisateurs interactifs +(`comptes_utilisateurs.md`), distincts des tokens API. + +### 3.2 Branchement + +Dans `src/app.py`, après l'init Dash : + +```python +from src.api import init_api +init_api(app.server) +``` + +`init_api` enregistre le blueprint sur `/api/v1` et expose : + +- `/api/v1/data` +- `/api/v1/schema` +- `/api/v1/health` +- `/api/v1/swagger` (UI) +- `/api/v1/openapi.json` + +### 3.3 Partage de la connexion DuckDB + +Les routes importent `src.db.conn` et utilisent les helpers existants +(`query_marches`, `count_marches`) ainsi que `src.db.schema` (Polars Schema) +pour la whitelist de colonnes. + +## 4. Stockage + +### 4.1 SQLite consolidée + +Une seule base SQLite, `users.sqlite` à la racine, contient : + +- `users` (futur — cf. `comptes_utilisateurs.md`) +- `api_tokens` (V1) + +Bénéfice : un seul fichier à sauvegarder et migrer ; la liaison future +`api_tokens.user_id → users.id` est immédiate sans migration de données. + +### 4.2 Schéma `api_tokens` + +```sql +CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + user_id INTEGER, + created_at TEXT NOT NULL, + last_used_at TEXT, + count_total INTEGER NOT NULL DEFAULT 0, + revoked_at TEXT +); +CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash); +``` + +`user_id` est `NULL` pour les tokens admin manuels. Quand le self-service +arrivera, il suffira de le renseigner. + +## 5. Endpoints + +### 5.1 Vue d'ensemble + +| Méthode | Path | Auth | Rôle | +| ------- | ---------------------- | ------ | ------------------------------------------- | +| GET | `/api/v1/data` | Bearer | Endpoint tabulaire principal | +| GET | `/api/v1/schema` | Bearer | Liste des colonnes (nom, type, description) | +| GET | `/api/v1/health` | Aucune | Sonde monitoring | +| GET | `/api/v1/swagger` | Aucune | Swagger UI | +| GET | `/api/v1/openapi.json` | Aucune | Spec OpenAPI | + +### 5.2 `/api/v1/data` — langage de requête + +Filtres en query string, opérateurs suffixés par `__` (mirror swagger cible) : + +| Opérateur | Sens | +| -------------------- | ----------------------------------------------------- | +| `__exact` | égalité | +| `__contains` | sous-chaîne (LIKE %v%) | +| `__notcontains` | négation de `__contains` | +| `__less` | ≤ | +| `__greater` | ≥ | +| `__strictly_less` | < | +| `__strictly_greater` | > | +| `__in` | liste séparée par virgules | +| `__notin` | négation de `__in` | +| `__isnull` | `IS NULL` (valeur ignorée) | +| `__isnotnull` | `IS NOT NULL` (valeur ignorée) | +| `__sort` | `asc` ou `desc` — ordre = ordre des params dans l'URL | + +Autres paramètres réservés : + +- `page` (int, défaut 1, ≥1) +- `page_size` (int, défaut 50, max 1000) +- `columns` (string, liste séparée par virgules ; défaut = toutes) +- `count` (bool, défaut `true` ; `false` → `meta.total` absent, économise un `COUNT(*)`) + +Exemple : + +``` +GET /api/v1/data?acheteur_departement_code__exact=44 + &dateNotification__greater=2024-01-01 + &montant__strictly_greater=100000 + &objet__contains=informatique + &cpv_8__in=72000000,72200000 + &dateNotification__sort=desc + &page=1 + &page_size=50 + &columns=uid,objet,montant,dateNotification +``` + +### 5.3 Sécurité du parsing + +`filters.py` est l'unique chemin de génération du `WHERE` SQL : + +1. Chaque clé `__` est splittée puis validée : + - `` doit être dans `src.db.schema` (whitelist stricte). + - `` doit être dans la liste blanche d'opérateurs. + - La valeur est convertie selon le type Polars de la colonne : + - `String` : utilisée telle quelle. + - `Int*` : `int(value)`, 400 si non parseable. + - `Float*` : `float(value)`, 400 si non parseable. + - `Date` / `Datetime` : ISO 8601 (`YYYY-MM-DD` ou `YYYY-MM-DDTHH:MM:SS`), 400 sinon. + - Booléens : **les colonnes booléennes sont stockées comme strings + "oui"/"non" en DuckDB** (cf. `src/db.py:43`), donc traitées comme + `String`. L'utilisateur filtre avec `colonne__exact=oui`. +2. Le `WHERE` est composé de fragments paramétrés (`?`) ; les valeurs + utilisateur sont passées au moteur DuckDB via les paramètres, **jamais + concaténées** dans le SQL. +3. Le résultat est consommé par `src.db.query_marches(where_sql=..., params=...)` + qui existe déjà. + +### 5.4 Format de réponse + +```json +{ + "data": [{ "uid": "...", "objet": "...", "montant": 12345.0 }], + "meta": { "page": 1, "page_size": 50, "total": 1234 }, + "links": { + "next": "/api/v1/data?...&page=2", + "prev": null + } +} +``` + +`meta.total` est omis si `count=false`. `links.next`/`links.prev` sont +`null` aux extrémités. + +### 5.5 `/api/v1/schema` + +```json +{ + "columns": [ + { "name": "uid", "type": "string", "description": "..." }, + { "name": "montant", "type": "float", "description": "..." } + ] +} +``` + +Descriptions tirées de `../decp-processing/reference/base_schema.json` si +disponible ; sinon vides. + +### 5.6 V1 : JSON only + +Pas de CSV / Parquet. Ajout possible plus tard via `?format=`. + +## 6. Authentification + +### 6.1 Transmission + +Header HTTP standard : + +``` +Authorization: Bearer dcpinfo_a1b2c3d4... +``` + +Pas de support via query string (fuites dans les logs). + +### 6.2 Format du token + +Préfixe `dcpinfo_` + 32 octets aléatoires hex (43 caractères au total). +Le préfixe facilite la détection de fuites (gitleaks, etc.). + +### 6.3 Hashing + +`sha256(token)` stocké dans `api_tokens.token_hash`. Pas de bcrypt/argon2 : +les tokens ont 256 bits d'entropie, le brute-force est impossible et un +hash lent ralentirait inutilement chaque requête API. + +### 6.4 Décorateur `@require_token` + +1. Lit `Authorization` ; absent → 401 `missing_token`. +2. Calcule `sha256`, `SELECT` indexé. +3. Pas trouvé → 401 `invalid_token`. +4. `revoked_at IS NOT NULL` → 401 `revoked_token`. +5. Pose `flask.g.token_id` pour `tracking.py`. + +### 6.5 CLI de gestion + +`python -m src.api.tokens_cli` : + +``` +create --label "Marie Dupont - étude transport 2026" + → affiche UNE FOIS le token plaintext (irrécupérable ensuite) + +list + → id | label | created_at | last_used_at | count_total | revoked? + +revoke + → set revoked_at = now() (ISO 8601 UTC) +``` + +Pas d'UI web pour les tokens en V1. + +## 7. Suivi de consommation + +### 7.1 Hook + +`@bp.after_request` déclenche deux actions **sans bloquer la réponse** : + +1. Enfilage d'un update SQLite dans une `queue.Queue` consommée par un + worker thread unique (writer série, pas de contention SQLite). +2. POST httpx fire-and-forget vers la Tracking API Matomo. + +Les erreurs des deux chemins sont loggées en `warning` mais jamais propagées +à l'utilisateur. + +### 7.2 Update SQLite + +```sql +UPDATE api_tokens +SET count_total = count_total + 1, + last_used_at = ? +WHERE id = ? +``` + +### 7.3 Event Matomo + +``` +POST https://analytics.maudry.com/matomo.php + idsite=14 + rec=1 + url=https://decp.info/api/v1/data? + action_name=API /data + uid=token- # jamais le token plaintext + dimension1= + dimension2= + ua= +``` + +Custom Dimensions à créer côté Matomo : `dimension1=token_id`, +`dimension2=http_status`. + +### 7.4 Variables d'environnement nouvelles + +``` +MATOMO_URL=https://analytics.maudry.com/matomo.php +MATOMO_SITE_ID=14 +MATOMO_TRACKING_ENABLED=true # false en dev/test par défaut +USERS_DB_PATH=./users.sqlite # tests : tests/users.test.sqlite +``` + +## 8. Erreurs + +Format uniforme (RFC 7807, déjà standard flask-smorest) : + +```json +{ + "code": 400, + "status": "Bad Request", + "message": "Colonne inconnue 'foo'.", + "errors": { "field": "foo__exact" } +} +``` + +| HTTP | Cas | +| ---- | --------------------------------------------------------------------- | +| 200 | Succès | +| 400 | Colonne/opérateur/valeur invalide, `page_size` hors bornes | +| 401 | `missing_token` / `invalid_token` / `revoked_token` | +| 404 | Path API inexistant | +| 500 | Exception non gérée — message générique, stack trace loggée seulement | + +Pas de 429 en V1. + +Les 4xx sont loggées en `info` (path + token_id), les 500 en `error` avec +stack trace. + +## 9. Tests + +Tests pytest purs (pas de Selenium) via `app.server.test_client()`. + +``` +tests/api/ +├── test_filters.py # parsing, génération SQL/params, erreurs +├── test_auth.py # 401 cases, last_used_at update +├── test_tokens_cli.py # create/list/revoke +├── test_endpoints_data.py # pagination, filtres, sort, columns, count=false +├── test_endpoints_schema.py # /schema renvoie les colonnes attendues +├── test_health.py # /health 200 sans auth +└── test_tracking.py # compteurs SQLite, Matomo désactivé par défaut + mock httpx +``` + +Fixtures pytest : + +- `api_client` : `app.server.test_client()` +- `valid_token_header` : crée un token dans `tests/users.test.sqlite`, renvoie le header `Authorization: Bearer …` +- `revoked_token_header` : idem avec `revoked_at` set + +Ajouts `pyproject.toml` `[tool.pytest.ini_options].env` : + +``` +USERS_DB_PATH=tests/users.test.sqlite +MATOMO_TRACKING_ENABLED=false +``` + +Couverture cible : 100% de `filters.py` et `auth.py` (sécurité-critique) ; +raisonnable ailleurs. + +## 10. Dépendances nouvelles + +À ajouter dans `pyproject.toml` : + +- `flask-smorest` (blueprint + OpenAPI + Swagger UI) +- `marshmallow` (déjà transitif de flask-smorest, à expliciter) + +`httpx` est déjà présent. Pas d'autres dépendances. + +## 11. Documentation utilisateur + +À fournir séparément (hors scope spec, à inclure dans le plan d'implémentation) : + +- Section "API" dans la page À propos ou page dédiée `/api` avec : + - lien vers Swagger UI + - exemples curl + - procédure pour obtenir un token (« contactez X ») +- Mention dans le `CHANGELOG.md` à la sortie de version. + +## 12. Risques et points ouverts + +- **Coût du `COUNT(*)`** sur gros filtres : mitigé par `count=false` opt-out. +- **Charge SQLite write** : un worker série suffira pour le trafic attendu + (admin tokens manuels, faible volume). Si le volume monte, passer à un + buffer en RAM avec flush périodique. +- **Matomo down** : impact nul sur l'API (fire-and-forget loggué). +- **Évolution vers self-service** : déjà préparée par `user_id` nullable et + séparation `src/api/` vs `src/auth/`. From 19d69e965f87f7a8f837dec383b335daca7e9e32 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 12:29:41 +0200 Subject: [PATCH 02/34] =?UTF-8?q?Format=20pr=C3=A9fixe=20token=20#78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/2026-05-13-api-privee-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-13-api-privee-design.md b/docs/superpowers/specs/2026-05-13-api-privee-design.md index 0a838c3..151f170 100644 --- a/docs/superpowers/specs/2026-05-13-api-privee-design.md +++ b/docs/superpowers/specs/2026-05-13-api-privee-design.md @@ -252,14 +252,14 @@ Pas de CSV / Parquet. Ajout possible plus tard via `?format=`. Header HTTP standard : ``` -Authorization: Bearer dcpinfo_a1b2c3d4... +Authorization: Bearer decpinfo_a1b2c3d4... ``` Pas de support via query string (fuites dans les logs). ### 6.2 Format du token -Préfixe `dcpinfo_` + 32 octets aléatoires hex (43 caractères au total). +Préfixe `decpinfo_` + 32 octets aléatoires hex (43 caractères au total). Le préfixe facilite la détection de fuites (gitleaks, etc.). ### 6.3 Hashing From 0363e89301e954fc01cbffe91d728af1b7d09e18 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 12:55:09 +0200 Subject: [PATCH 03/34] =?UTF-8?q?Plan=20:=20impl=C3=A9mentation=20API=20pr?= =?UTF-8?q?iv=C3=A9e=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 tâches TDD couvrant deps, tokens_db, CLI, filters, auth, blueprint, endpoints (/health, /schema, /data), tracking SQLite + Matomo, OpenAPI, changelog, mention page À propos. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-05-13-api-privee.md | 1992 +++++++++++++++++ 1 file changed, 1992 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-api-privee.md diff --git a/docs/superpowers/plans/2026-05-13-api-privee.md b/docs/superpowers/plans/2026-05-13-api-privee.md new file mode 100644 index 0000000..c9ad70a --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-api-privee.md @@ -0,0 +1,1992 @@ +# API privée decp.info — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Livrer une API HTTP privée tabulaire `/api/v1/data` protégée par tokens Bearer, documentée via OpenAPI/Swagger, avec suivi de consommation (Matomo + compteurs SQLite). + +**Architecture:** Blueprint flask-smorest monté sur le serveur Flask existant (sous Dash), partage la connexion DuckDB read-only de `src/db.py`. Tokens stockés hashés (SHA-256) dans `users.sqlite`, CLI d'administration. Suivi async par worker thread (SQLite) + httpx fire-and-forget (Matomo). + +**Tech Stack:** Flask, flask-smorest, marshmallow, DuckDB (existant), SQLite (stdlib), httpx (existant), Polars (existant), pytest. + +**Spec de référence :** `docs/superpowers/specs/2026-05-13-api-privee-design.md` + +--- + +## File Structure + +**À créer :** + +``` +src/api/ +├── __init__.py # init_api(server) +├── routes.py # endpoints /data, /schema, /health +├── schemas.py # marshmallow request/response schemas +├── filters.py # parsing & validation des filtres +├── auth.py # @require_token +├── tracking.py # worker SQLite + Matomo httpx +├── tokens_db.py # CRUD api_tokens +└── tokens_cli.py # python -m src.api.tokens_cli ... + +tests/api/ +├── __init__.py +├── conftest.py # fixtures api_client, valid_token_header, revoked_token_header +├── test_tokens_db.py +├── test_tokens_cli.py +├── test_filters.py +├── test_auth.py +├── test_health.py +├── test_endpoints_schema.py +├── test_endpoints_data.py +└── test_tracking.py +``` + +**À modifier :** + +- `pyproject.toml` : nouvelles deps + nouvelles variables `pytest-env` +- `.template.env` : nouvelles variables `MATOMO_*` et `USERS_DB_PATH` +- `src/app.py` : appel à `init_api(app.server)` après l'init Dash +- `CHANGELOG.md` : entrée pour la version qui livre l'API + +--- + +## Task 1: Dépendances et infrastructure de test + +**Files:** + +- Modify: `pyproject.toml` +- Modify: `.template.env` +- Create: `src/api/__init__.py` (vide) +- Create: `tests/api/__init__.py` (vide) + +- [ ] **Step 1: Ajouter `flask-smorest` aux dépendances** + +Dans `pyproject.toml`, section `[project].dependencies`, ajouter après `"flask-cors>=6.0.2"` : + +```toml + "flask-smorest>=0.46.0", + "marshmallow>=3.20.0", +``` + +- [ ] **Step 2: Ajouter les variables d'env pytest** + +Dans `pyproject.toml`, section `[tool.pytest.ini_options].env`, ajouter à la liste : + +```toml + "USERS_DB_PATH=tests/users.test.sqlite", + "MATOMO_TRACKING_ENABLED=false", +``` + +- [ ] **Step 3: Étendre `.template.env`** + +Ajouter à la fin de `.template.env` : + +```bash + +# API privée +USERS_DB_PATH=./users.sqlite +MATOMO_URL=https://analytics.maudry.com/matomo.php +MATOMO_SITE_ID=14 +MATOMO_TRACKING_ENABLED=true +``` + +- [ ] **Step 4: Installer les nouvelles dépendances** + +Run: `source .venv/bin/activate && rtk pip install -e . --group=dev` + +Expected: install OK, `pip show flask-smorest` shows version. + +- [ ] **Step 5: Créer les packages vides** + +Créer `src/api/__init__.py` avec contenu vide (juste un commentaire). + +```python +# decp.info — API privée. init_api(server) sera ajouté Task 6. +``` + +Créer `tests/api/__init__.py` avec contenu vide. + +- [ ] **Step 6: Vérifier que pytest tourne toujours** + +Run: `rtk pre-commit run --all-files` +Expected: PASS (formatages éventuels appliqués). + +Run: `rtk pytest tests/ -x --co -q 2>&1 | tail -20` +Expected: la collection se fait sans erreur (peu importe le nb de tests). + +- [ ] **Step 7: Commit** + +```bash +rtk git add pyproject.toml .template.env src/api/__init__.py tests/api/__init__.py +rtk git commit -m "API: dépendances et squelette de package (#78)" +``` + +--- + +## Task 2: Module `tokens_db` (SQLite CRUD) + +**Files:** + +- Create: `src/api/tokens_db.py` +- Create: `tests/api/conftest.py` +- Create: `tests/api/test_tokens_db.py` + +- [ ] **Step 1: Écrire la fixture `temp_db` partagée** + +Créer `tests/api/conftest.py` : + +```python +import os +from pathlib import Path + +import pytest + + +@pytest.fixture +def temp_db(tmp_path, monkeypatch): + """Une SQLite éphémère pour les tests qui modifient la DB.""" + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from src.api import tokens_db + tokens_db.init_schema(db_path) + return db_path +``` + +- [ ] **Step 2: Écrire les tests de `tokens_db`** + +Créer `tests/api/test_tokens_db.py` : + +```python +import sqlite3 + +from src.api import tokens_db + + +def test_init_schema_creates_table(temp_db): + with sqlite3.connect(str(temp_db)) as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'" + ).fetchall() + assert rows == [("api_tokens",)] + + +def test_create_token_returns_plaintext_and_stores_hash(temp_db): + token, token_id = tokens_db.create_token(temp_db, "test-label") + assert token.startswith("decpinfo_") + assert len(token) == len("decpinfo_") + 64 # 32 octets hex = 64 chars + assert token_id >= 1 + + with sqlite3.connect(str(temp_db)) as conn: + row = conn.execute( + "SELECT token_hash, label, count_total FROM api_tokens WHERE id = ?", + (token_id,), + ).fetchone() + assert row[1] == "test-label" + assert row[2] == 0 + assert row[0] != token # stocké en clair impossible + assert len(row[0]) == 64 # sha256 hex + + +def test_get_token_by_plaintext_returns_row(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row is not None + assert row["id"] == token_id + assert row["label"] == "x" + assert row["revoked_at"] is None + + +def test_get_token_unknown_returns_none(temp_db): + assert tokens_db.get_token_by_plaintext(temp_db, "decpinfo_zzz") is None + + +def test_revoke_token_sets_revoked_at(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.revoke_token(temp_db, token_id) + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row["revoked_at"] is not None + + +def test_increment_usage_updates_counter_and_timestamp(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.increment_usage(temp_db, token_id) + tokens_db.increment_usage(temp_db, token_id) + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row["count_total"] == 2 + assert row["last_used_at"] is not None + + +def test_list_tokens_returns_all(temp_db): + tokens_db.create_token(temp_db, "a") + tokens_db.create_token(temp_db, "b") + rows = tokens_db.list_tokens(temp_db) + assert [r["label"] for r in rows] == ["a", "b"] +``` + +- [ ] **Step 3: Lancer les tests et confirmer qu'ils échouent** + +Run: `rtk pytest tests/api/test_tokens_db.py -v` +Expected: tous les tests FAIL avec `ImportError` ou `AttributeError` (module non écrit). + +- [ ] **Step 4: Implémenter `tokens_db.py`** + +Créer `src/api/tokens_db.py` : + +```python +import hashlib +import secrets +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +TOKEN_PREFIX = "decpinfo_" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + user_id INTEGER, + created_at TEXT NOT NULL, + last_used_at TEXT, + count_total INTEGER NOT NULL DEFAULT 0, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); +""" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +@contextmanager +def _connect(db_path): + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +def init_schema(db_path) -> None: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + with _connect(db_path) as conn: + conn.executescript(SCHEMA) + conn.commit() + + +def create_token(db_path, label: str, user_id: int | None = None) -> tuple[str, int]: + token = TOKEN_PREFIX + secrets.token_hex(32) + with _connect(db_path) as conn: + cur = conn.execute( + "INSERT INTO api_tokens (token_hash, label, user_id, created_at) " + "VALUES (?, ?, ?, ?)", + (_hash(token), label, user_id, _utcnow_iso()), + ) + conn.commit() + return token, cur.lastrowid + + +def get_token_by_plaintext(db_path, token: str) -> dict | None: + with _connect(db_path) as conn: + row = conn.execute( + "SELECT * FROM api_tokens WHERE token_hash = ?", + (_hash(token),), + ).fetchone() + return dict(row) if row else None + + +def revoke_token(db_path, token_id: int) -> None: + with _connect(db_path) as conn: + conn.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE id = ?", + (_utcnow_iso(), token_id), + ) + conn.commit() + + +def increment_usage(db_path, token_id: int) -> None: + with _connect(db_path) as conn: + conn.execute( + "UPDATE api_tokens " + "SET count_total = count_total + 1, last_used_at = ? " + "WHERE id = ?", + (_utcnow_iso(), token_id), + ) + conn.commit() + + +def list_tokens(db_path) -> list[dict]: + with _connect(db_path) as conn: + rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall() + return [dict(r) for r in rows] +``` + +- [ ] **Step 5: Lancer les tests, confirmer qu'ils passent** + +Run: `rtk pytest tests/api/test_tokens_db.py -v` +Expected: PASS (7 tests). + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/tokens_db.py tests/api/conftest.py tests/api/test_tokens_db.py +rtk git add src/api/tokens_db.py tests/api/conftest.py tests/api/test_tokens_db.py +rtk git commit -m "API: tokens_db SQLite CRUD (#78)" +``` + +--- + +## Task 3: CLI `tokens_cli` (create / list / revoke) + +**Files:** + +- Create: `src/api/tokens_cli.py` +- Create: `tests/api/test_tokens_cli.py` + +- [ ] **Step 1: Écrire les tests CLI** + +Créer `tests/api/test_tokens_cli.py` : + +```python +import subprocess +import sys + +from src.api import tokens_cli, tokens_db + + +def _run(args, env): + return tokens_cli.main(args, env=env) + + +def test_create_prints_plaintext_token_once(temp_db, capsys): + rc = _run(["create", "--label", "alice"], env={"USERS_DB_PATH": str(temp_db)}) + out = capsys.readouterr().out + assert rc == 0 + assert "decpinfo_" in out + tokens = tokens_db.list_tokens(temp_db) + assert len(tokens) == 1 + assert tokens[0]["label"] == "alice" + + +def test_list_shows_tokens(temp_db, capsys): + tokens_db.create_token(temp_db, "alice") + tokens_db.create_token(temp_db, "bob") + rc = _run(["list"], env={"USERS_DB_PATH": str(temp_db)}) + out = capsys.readouterr().out + assert rc == 0 + assert "alice" in out + assert "bob" in out + + +def test_revoke_sets_revoked_at(temp_db, capsys): + _, token_id = tokens_db.create_token(temp_db, "alice") + rc = _run( + ["revoke", str(token_id)], env={"USERS_DB_PATH": str(temp_db)} + ) + assert rc == 0 + tokens = tokens_db.list_tokens(temp_db) + assert tokens[0]["revoked_at"] is not None +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_tokens_cli.py -v` +Expected: FAIL (`ImportError`). + +- [ ] **Step 3: Implémenter `tokens_cli.py`** + +Créer `src/api/tokens_cli.py` : + +```python +import argparse +import os +import sys + +from src.api import tokens_db + + +def main(argv=None, env=None) -> int: + env = env if env is not None else os.environ + parser = argparse.ArgumentParser(prog="python -m src.api.tokens_cli") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_create = sub.add_parser("create", help="Créer un token API") + p_create.add_argument("--label", required=True) + p_create.add_argument("--user-id", type=int, default=None) + + sub.add_parser("list", help="Lister les tokens") + + p_revoke = sub.add_parser("revoke", help="Révoquer un token") + p_revoke.add_argument("token_id", type=int) + + args = parser.parse_args(argv) + db_path = env["USERS_DB_PATH"] + tokens_db.init_schema(db_path) + + if args.cmd == "create": + token, token_id = tokens_db.create_token(db_path, args.label, args.user_id) + print(f"id={token_id} label={args.label}") + print(f"token (à conserver, ne sera plus affiché) : {token}") + return 0 + + if args.cmd == "list": + rows = tokens_db.list_tokens(db_path) + if not rows: + print("(aucun token)") + return 0 + print(f"{'id':<4} {'label':<40} {'created_at':<26} {'last_used_at':<26} {'count':<7} revoked") + for r in rows: + print( + f"{r['id']:<4} {r['label']:<40} {r['created_at']:<26} " + f"{(r['last_used_at'] or '-'):<26} {r['count_total']:<7} " + f"{r['revoked_at'] or ''}" + ) + return 0 + + if args.cmd == "revoke": + tokens_db.revoke_token(db_path, args.token_id) + print(f"token id={args.token_id} révoqué") + return 0 + + return 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) +``` + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_tokens_cli.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Tester manuellement le CLI** + +```bash +USERS_DB_PATH=/tmp/cli.test.sqlite python -m src.api.tokens_cli create --label "test" +USERS_DB_PATH=/tmp/cli.test.sqlite python -m src.api.tokens_cli list +``` + +Expected: token affiché en sortie, puis listé. + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/tokens_cli.py tests/api/test_tokens_cli.py +rtk git add src/api/tokens_cli.py tests/api/test_tokens_cli.py +rtk git commit -m "API: CLI tokens_cli (create/list/revoke) (#78)" +``` + +--- + +## Task 4: Module `filters` (parsing + génération SQL) + +**Files:** + +- Create: `src/api/filters.py` +- Create: `tests/api/test_filters.py` + +- [ ] **Step 1: Écrire les tests de parsing & validation** + +Créer `tests/api/test_filters.py` : + +```python +import polars as pl +import pytest + +from src.api.filters import FilterError, build_where + + +SCHEMA = pl.Schema( + { + "uid": pl.String, + "objet": pl.String, + "montant": pl.Float64, + "annee": pl.Int64, + "dateNotification": pl.Date, + } +) + + +def test_no_filters_returns_true(): + where, params, order = build_where([], SCHEMA) + assert where == "TRUE" + assert params == [] + assert order is None + + +def test_exact_filter(): + where, params, _ = build_where([("uid__exact", "abc")], SCHEMA) + assert where == '"uid" = ?' + assert params == ["abc"] + + +def test_contains_filter_uses_like_wildcards(): + where, params, _ = build_where( + [("objet__contains", "informatique")], SCHEMA + ) + assert where == '"objet" LIKE ?' + assert params == ["%informatique%"] + + +def test_notcontains_filter(): + where, params, _ = build_where( + [("objet__notcontains", "x")], SCHEMA + ) + assert where == '"objet" NOT LIKE ?' + assert params == ["%x%"] + + +def test_comparison_operators_on_int(): + where, params, _ = build_where( + [("annee__strictly_greater", "2023")], SCHEMA + ) + assert where == '"annee" > ?' + assert params == [2024 - 1] # int coercion: 2023 + + +def test_in_filter_splits_on_commas(): + where, params, _ = build_where( + [("annee__in", "2022,2023,2024")], SCHEMA + ) + assert where == '"annee" IN (?,?,?)' + assert params == [2022, 2023, 2024] + + +def test_notin_filter(): + where, params, _ = build_where( + [("annee__notin", "2020,2021")], SCHEMA + ) + assert where == '"annee" NOT IN (?,?)' + assert params == [2020, 2021] + + +def test_isnull_filter_ignores_value(): + where, params, _ = build_where( + [("dateNotification__isnull", "anything")], SCHEMA + ) + assert where == '"dateNotification" IS NULL' + assert params == [] + + +def test_isnotnull_filter(): + where, params, _ = build_where( + [("dateNotification__isnotnull", "")], SCHEMA + ) + assert where == '"dateNotification" IS NOT NULL' + + +def test_multiple_filters_joined_by_and(): + where, params, _ = build_where( + [("uid__exact", "a"), ("annee__greater", "2020")], SCHEMA + ) + assert where == '"uid" = ? AND "annee" >= ?' + assert params == ["a", 2020] + + +def test_unknown_column_raises(): + with pytest.raises(FilterError) as exc: + build_where([("foo__exact", "bar")], SCHEMA) + assert "foo" in str(exc.value) + assert exc.value.field == "foo__exact" + + +def test_unknown_operator_raises(): + with pytest.raises(FilterError) as exc: + build_where([("uid__weird", "x")], SCHEMA) + assert "weird" in str(exc.value) + + +def test_bad_int_value_raises(): + with pytest.raises(FilterError): + build_where([("annee__exact", "notanint")], SCHEMA) + + +def test_bad_date_value_raises(): + with pytest.raises(FilterError): + build_where([("dateNotification__exact", "notadate")], SCHEMA) + + +def test_date_iso_coercion(): + where, params, _ = build_where( + [("dateNotification__greater", "2024-01-01")], SCHEMA + ) + from datetime import date + assert params == [date(2024, 1, 1)] + + +def test_reserved_params_are_ignored(): + where, params, order = build_where( + [ + ("page", "2"), + ("page_size", "100"), + ("columns", "uid"), + ("count", "false"), + ("uid__exact", "z"), + ], + SCHEMA, + ) + assert where == '"uid" = ?' + assert params == ["z"] + + +def test_sort_returns_order_by(): + where, params, order = build_where( + [("annee__sort", "desc"), ("uid__sort", "asc")], SCHEMA + ) + assert where == "TRUE" + assert order == '"annee" DESC, "uid" ASC' + + +def test_sort_invalid_direction_raises(): + with pytest.raises(FilterError): + build_where([("uid__sort", "sideways")], SCHEMA) + + +def test_param_without_operator_raises(): + with pytest.raises(FilterError): + build_where([("uidexact", "x")], SCHEMA) +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_filters.py -v` +Expected: FAIL avec `ImportError`. + +- [ ] **Step 3: Implémenter `filters.py`** + +Créer `src/api/filters.py` : + +```python +from datetime import date, datetime + +import polars as pl + +OPERATORS = { + "exact", + "contains", + "notcontains", + "less", + "greater", + "strictly_less", + "strictly_greater", + "in", + "notin", + "isnull", + "isnotnull", + "sort", +} + +RESERVED_PARAMS = {"page", "page_size", "columns", "count"} + + +class FilterError(ValueError): + def __init__(self, message: str, field: str | None = None): + super().__init__(message) + self.field = field + + +def _coerce(value: str, dtype: pl.DataType, key: str): + if dtype == pl.String: + return value + if dtype.is_integer(): + try: + return int(value) + except ValueError: + raise FilterError( + f"Valeur entière attendue, reçu {value!r}", field=key + ) + if dtype.is_float(): + try: + return float(value) + except ValueError: + raise FilterError( + f"Valeur décimale attendue, reçu {value!r}", field=key + ) + if dtype == pl.Date: + try: + return date.fromisoformat(value) + except ValueError: + raise FilterError( + f"Date ISO 8601 attendue (YYYY-MM-DD), reçu {value!r}", field=key + ) + if dtype == pl.Datetime: + try: + return datetime.fromisoformat(value) + except ValueError: + raise FilterError( + f"Datetime ISO 8601 attendu, reçu {value!r}", field=key + ) + return value + + +def _split_key(key: str) -> tuple[str, str] | None: + if "__" not in key: + return None + col, _, op = key.rpartition("__") + if not col or not op: + return None + return col, op + + +def build_where( + args: list[tuple[str, str]], schema: pl.Schema +) -> tuple[str, list, str | None]: + """Parse query params into (where_sql, params, order_by_sql). + + args: list of (key, value) tuples preserving URL order (Werkzeug MultiDict + preserves insertion order on `request.args.items(multi=True)`). + """ + where_parts: list[str] = [] + params: list = [] + order_parts: list[str] = [] + + for key, value in args: + if key in RESERVED_PARAMS: + continue + + parsed = _split_key(key) + if not parsed: + raise FilterError(f"Paramètre non reconnu : {key}", field=key) + col, op = parsed + + if op not in OPERATORS: + raise FilterError(f"Opérateur inconnu : __{op}", field=key) + + if col not in schema: + raise FilterError(f"Colonne inconnue : {col!r}", field=key) + + if op == "sort": + direction = value.lower() + if direction not in ("asc", "desc"): + raise FilterError( + f"Tri attendu 'asc' ou 'desc', reçu {value!r}", field=key + ) + order_parts.append(f'"{col}" {direction.upper()}') + continue + + if op in ("isnull", "isnotnull"): + sql = "IS NULL" if op == "isnull" else "IS NOT NULL" + where_parts.append(f'"{col}" {sql}') + continue + + dtype = schema[col] + + if op in ("in", "notin"): + values = [_coerce(v.strip(), dtype, key) for v in value.split(",")] + placeholders = ",".join(["?"] * len(values)) + sql_op = "IN" if op == "in" else "NOT IN" + where_parts.append(f'"{col}" {sql_op} ({placeholders})') + params.extend(values) + continue + + v = _coerce(value, dtype, key) + + op_sql = { + "exact": "=", + "less": "<=", + "greater": ">=", + "strictly_less": "<", + "strictly_greater": ">", + } + if op in op_sql: + where_parts.append(f'"{col}" {op_sql[op]} ?') + params.append(v) + elif op == "contains": + where_parts.append(f'"{col}" LIKE ?') + params.append(f"%{v}%") + elif op == "notcontains": + where_parts.append(f'"{col}" NOT LIKE ?') + params.append(f"%{v}%") + + where_sql = " AND ".join(where_parts) if where_parts else "TRUE" + order_sql = ", ".join(order_parts) if order_parts else None + return where_sql, params, order_sql +``` + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_filters.py -v` +Expected: PASS (~20 tests). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files src/api/filters.py tests/api/test_filters.py +rtk git add src/api/filters.py tests/api/test_filters.py +rtk git commit -m "API: parser de filtres et génération SQL paramétré (#78)" +``` + +--- + +## Task 5: Décorateur `@require_token` + +**Files:** + +- Create: `src/api/auth.py` +- Create: `tests/api/test_auth.py` + +- [ ] **Step 1: Écrire les tests d'auth** + +Créer `tests/api/test_auth.py` : + +```python +from flask import Flask, g, jsonify + +from src.api import tokens_db +from src.api.auth import require_token + + +def _make_app(): + app = Flask(__name__) + + @app.route("/protected") + @require_token + def protected(): + return jsonify({"token_id": g.token_id}) + + return app + + +def test_missing_header_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get("/protected") + assert resp.status_code == 401 + assert resp.get_json()["message"] == "missing_token" + + +def test_bearer_without_value_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": "Bearer "} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "missing_token" + + +def test_invalid_token_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": "Bearer decpinfo_unknown"} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "invalid_token" + + +def test_revoked_token_returns_401(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.revoke_token(temp_db, token_id) + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": f"Bearer {token}"} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "revoked_token" + + +def test_valid_token_sets_g_and_calls_view(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": f"Bearer {token}"} + ) + assert resp.status_code == 200 + assert resp.get_json()["token_id"] == token_id +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_auth.py -v` +Expected: FAIL avec `ImportError` sur `src.api.auth`. + +- [ ] **Step 3: Implémenter `auth.py`** + +Créer `src/api/auth.py` : + +```python +import os +from functools import wraps + +from flask import abort, g, jsonify, make_response, request + +from src.api import tokens_db + + +def _abort_401(message: str): + resp = make_response(jsonify({"message": message}), 401) + abort(resp) + + +def require_token(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + _abort_401("missing_token") + token = header[len("Bearer ") :].strip() + if not token: + _abort_401("missing_token") + db_path = os.environ["USERS_DB_PATH"] + row = tokens_db.get_token_by_plaintext(db_path, token) + if row is None: + _abort_401("invalid_token") + if row["revoked_at"] is not None: + _abort_401("revoked_token") + g.token_id = row["id"] + return fn(*args, **kwargs) + + return wrapper +``` + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_auth.py -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files src/api/auth.py tests/api/test_auth.py +rtk git add src/api/auth.py tests/api/test_auth.py +rtk git commit -m "API: décorateur @require_token (#78)" +``` + +--- + +## Task 6: Squelette de blueprint + `/health` + +**Files:** + +- Modify: `src/api/__init__.py` +- Create: `src/api/routes.py` +- Create: `tests/api/test_health.py` + +- [ ] **Step 1: Écrire le test `/health`** + +Créer `tests/api/test_health.py` : + +```python +from flask import Flask + +from src.api import init_api + + +def _make_app(): + app = Flask(__name__) + init_api(app) + return app + + +def test_health_returns_ok_without_auth(): + app = _make_app() + resp = app.test_client().get("/api/v1/health") + assert resp.status_code == 200 + assert resp.get_json() == {"status": "ok"} +``` + +- [ ] **Step 2: Lancer le test, confirmer FAIL** + +Run: `rtk pytest tests/api/test_health.py -v` +Expected: FAIL (`init_api` n'existe pas). + +- [ ] **Step 3: Implémenter `__init__.py` avec blueprint flask-smorest** + +Remplacer le contenu de `src/api/__init__.py` : + +```python +from flask_smorest import Api, Blueprint + +from src.api import routes + + +def init_api(server) -> None: + """Enregistre le blueprint d'API privée sur le serveur Flask.""" + server.config.setdefault("API_TITLE", "decp.info API") + server.config.setdefault("API_VERSION", "v1") + server.config.setdefault("OPENAPI_VERSION", "3.0.3") + server.config.setdefault("OPENAPI_URL_PREFIX", "/api/v1") + server.config.setdefault("OPENAPI_JSON_PATH", "openapi.json") + server.config.setdefault("OPENAPI_SWAGGER_UI_PATH", "swagger") + server.config.setdefault( + "OPENAPI_SWAGGER_UI_URL", + "https://cdn.jsdelivr.net/npm/swagger-ui-dist/", + ) + + api = Api(server) + api.register_blueprint(routes.bp) +``` + +- [ ] **Step 4: Implémenter `routes.py` avec uniquement `/health`** + +Créer `src/api/routes.py` : + +```python +from flask_smorest import Blueprint + +bp = Blueprint( + "api_v1", + "api_v1", + url_prefix="/api/v1", + description="API privée decp.info — accès tabulaire aux marchés publics.", +) + + +@bp.route("/health") +def health(): + """Sonde de santé, sans authentification.""" + return {"status": "ok"} +``` + +- [ ] **Step 5: Lancer le test, confirmer PASS** + +Run: `rtk pytest tests/api/test_health.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/__init__.py src/api/routes.py tests/api/test_health.py +rtk git add src/api/__init__.py src/api/routes.py tests/api/test_health.py +rtk git commit -m "API: blueprint flask-smorest + endpoint /health (#78)" +``` + +--- + +## Task 7: Brancher l'API dans l'app Dash + +**Files:** + +- Modify: `src/app.py` + +- [ ] **Step 1: Écrire un test d'intégration** + +Ajouter à la fin de `tests/api/test_health.py` : + +```python +def test_health_via_real_app(): + """Vérifie que init_api est bien branché dans src.app.""" + from src.app import app as dash_app + + resp = dash_app.server.test_client().get("/api/v1/health") + assert resp.status_code == 200 +``` + +- [ ] **Step 2: Lancer le test, confirmer FAIL** + +Run: `rtk pytest tests/api/test_health.py::test_health_via_real_app -v` +Expected: FAIL (404 — l'API n'est pas branchée). + +- [ ] **Step 3: Brancher `init_api` dans `src/app.py`** + +Dans `src/app.py`, après la ligne `cache.init_app(...)` (vers la ligne 52, juste avant la définition de `@app.server.route("/robots.txt")`), ajouter : + +```python +from src.api import init_api + +init_api(app.server) +``` + +L'import en haut du fichier serait plus propre mais romprait l'ordre des dépendances (l'API a besoin que `src.db.conn` soit prêt). Le faire ici garantit l'ordre. + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_health.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 5: Vérifier que l'app Dash démarre toujours** + +Run: `rtk pytest tests/ -x --co -q 2>&1 | tail -5` +Expected: collection sans erreur. + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/app.py tests/api/test_health.py +rtk git add src/app.py tests/api/test_health.py +rtk git commit -m "API: branchement init_api dans l'app Dash (#78)" +``` + +--- + +## Task 8: Endpoint `/schema` + +**Files:** + +- Modify: `src/api/routes.py` +- Create: `tests/api/test_endpoints_schema.py` + +- [ ] **Step 1: Étendre `tests/api/conftest.py` avec une fixture token "vivant"** + +Ajouter à `tests/api/conftest.py` (à la fin) : + +```python +@pytest.fixture +def api_client(monkeypatch, tmp_path): + """Client Flask test avec USERS_DB_PATH éphémère et blueprint API monté.""" + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from flask import Flask + + from src.api import init_api, tokens_db + + tokens_db.init_schema(db_path) + server = Flask(__name__) + init_api(server) + return server.test_client(), db_path + + +@pytest.fixture +def valid_token_header(api_client): + from src.api import tokens_db + + _, db_path = api_client + token, _ = tokens_db.create_token(db_path, "test-token") + return {"Authorization": f"Bearer {token}"} +``` + +- [ ] **Step 2: Écrire les tests de `/schema`** + +Créer `tests/api/test_endpoints_schema.py` : + +```python +def test_schema_without_token_returns_401(api_client): + client, _ = api_client + resp = client.get("/api/v1/schema") + assert resp.status_code == 401 + + +def test_schema_returns_columns(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/schema", headers=valid_token_header) + assert resp.status_code == 200 + data = resp.get_json() + assert "columns" in data + assert isinstance(data["columns"], list) + assert len(data["columns"]) > 0 + first = data["columns"][0] + assert set(first.keys()) >= {"name", "type"} + # uid doit être présent dans le schéma DECP de test + names = [c["name"] for c in data["columns"]] + assert "uid" in names +``` + +- [ ] **Step 3: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_endpoints_schema.py -v` +Expected: FAIL (route /schema absente → 404). + +- [ ] **Step 4: Implémenter `/schema` dans `routes.py`** + +Ajouter à la fin de `src/api/routes.py` : + +```python +from src.api.auth import require_token +from src.db import schema as duckdb_schema + + +@bp.route("/schema") +@require_token +def schema(): + """Liste des colonnes disponibles dans le dataset DECP.""" + cols = [ + {"name": name, "type": str(dtype)} + for name, dtype in duckdb_schema.items() + ] + return {"columns": cols} +``` + +- [ ] **Step 5: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_endpoints_schema.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/routes.py tests/api/conftest.py tests/api/test_endpoints_schema.py +rtk git add src/api/routes.py tests/api/conftest.py tests/api/test_endpoints_schema.py +rtk git commit -m "API: endpoint /schema (#78)" +``` + +--- + +## Task 9: Endpoint `/data` — pagination de base sans filtres + +**Files:** + +- Modify: `src/api/routes.py` +- Create: `tests/api/test_endpoints_data.py` + +- [ ] **Step 1: Écrire les premiers tests `/data`** + +Créer `tests/api/test_endpoints_data.py` : + +```python +def test_data_without_token_returns_401(api_client): + client, _ = api_client + resp = client.get("/api/v1/data") + assert resp.status_code == 401 + + +def test_data_default_pagination(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert set(body.keys()) >= {"data", "meta", "links"} + assert isinstance(body["data"], list) + assert len(body["data"]) <= 50 # default page_size + assert body["meta"]["page"] == 1 + assert body["meta"]["page_size"] == 50 + assert "total" in body["meta"] + + +def test_data_count_false_omits_total(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?count=false", headers=valid_token_header + ) + assert resp.status_code == 200 + body = resp.get_json() + assert "total" not in body["meta"] + + +def test_data_page_size_max_enforced(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?page_size=5000", headers=valid_token_header + ) + assert resp.status_code == 400 + + +def test_data_page_size_below_min_rejected(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?page_size=0", headers=valid_token_header + ) + assert resp.status_code == 400 + + +def test_data_pagination_links(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?page=1&page_size=1", headers=valid_token_header + ) + body = resp.get_json() + assert body["links"]["prev"] is None + if body["meta"]["total"] > 1: + assert body["links"]["next"] is not None + assert "page=2" in body["links"]["next"] +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_endpoints_data.py -v` +Expected: FAIL (404 sur /data). + +- [ ] **Step 3: Ajouter la route `/data` dans `routes.py`** + +Ajouter à `src/api/routes.py` (après `/schema`) : + +```python +from flask import request +from flask_smorest import abort + +from src.api.filters import FilterError, build_where +from src.db import count_marches, query_marches, schema as duckdb_schema + + +MAX_PAGE_SIZE = 1000 + + +def _parse_pagination(): + try: + page = int(request.args.get("page", "1")) + page_size = int(request.args.get("page_size", "50")) + except ValueError: + abort(400, message="page et page_size doivent être des entiers") + if page < 1: + abort(400, message="page doit être >= 1") + if page_size < 1 or page_size > MAX_PAGE_SIZE: + abort( + 400, + message=f"page_size doit être dans [1, {MAX_PAGE_SIZE}]", + ) + return page, page_size + + +def _parse_columns(): + raw = request.args.get("columns") + if not raw: + return None + cols = [c.strip() for c in raw.split(",") if c.strip()] + unknown = [c for c in cols if c not in duckdb_schema] + if unknown: + abort(400, message=f"Colonnes inconnues : {unknown}") + return cols + + +def _build_links(page, page_size, total): + base = request.path + qs = request.args.to_dict(flat=False) + qs.pop("page", None) + + def url_for(p): + from urllib.parse import urlencode + params = [(k, v) for k, vs in qs.items() for v in vs] + params.append(("page", str(p))) + return f"{base}?{urlencode(params)}" + + prev_url = url_for(page - 1) if page > 1 else None + next_url = None + if total is None or page * page_size < total: + next_url = url_for(page + 1) + return {"prev": prev_url, "next": next_url} + + +@bp.route("/data") +@require_token +def data(): + """Endpoint tabulaire : filtres dynamiques sur les colonnes DECP.""" + page, page_size = _parse_pagination() + columns = _parse_columns() + count = request.args.get("count", "true").lower() != "false" + + try: + where_sql, params, order_sql = build_where( + list(request.args.items(multi=True)), duckdb_schema + ) + except FilterError as e: + abort(400, message=str(e), errors={"field": e.field}) + + df = query_marches( + where_sql=where_sql, + params=params, + columns=columns, + order_by=order_sql, + limit=page_size, + offset=(page - 1) * page_size, + ) + + # JSON ne sérialise pas date/datetime nativement → cast en string ISO + import polars as pl + import polars.selectors as cs + + df_ready = df.with_columns(cs.temporal().cast(pl.String)) + + total = count_marches(where_sql, params) if count else None + meta = {"page": page, "page_size": page_size} + if total is not None: + meta["total"] = total + + return { + "data": df_ready.to_dicts(), + "meta": meta, + "links": _build_links(page, page_size, total), + } +``` + +- [ ] **Step 4: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_endpoints_data.py -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files src/api/routes.py tests/api/test_endpoints_data.py +rtk git add src/api/routes.py tests/api/test_endpoints_data.py +rtk git commit -m "API: endpoint /data avec pagination et filtres (#78)" +``` + +--- + +## Task 10: Endpoint `/data` — filtres, tri, columns (intégration) + +**Files:** + +- Modify: `tests/api/test_endpoints_data.py` + +- [ ] **Step 1: Ajouter les tests d'intégration filtres + tri + columns** + +Ajouter à `tests/api/test_endpoints_data.py` : + +```python +def test_data_filter_exact_string(api_client, valid_token_header): + client, _ = api_client + # On choisit une valeur qui existe dans test.parquet : récupère via la 1re ligne + base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json() + assert base["data"], "test.parquet vide ?" + uid = base["data"][0]["uid"] + + resp = client.get( + f"/api/v1/data?uid__exact={uid}", headers=valid_token_header + ) + assert resp.status_code == 200 + body = resp.get_json() + assert all(row["uid"] == uid for row in body["data"]) + + +def test_data_unknown_column_filter_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?colonne_inexistante__exact=x", + headers=valid_token_header, + ) + assert resp.status_code == 400 + + +def test_data_columns_selection(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?columns=uid,objet&page_size=3", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + for row in body["data"]: + assert set(row.keys()) == {"uid", "objet"} + + +def test_data_columns_unknown_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?columns=uid,foobar", + headers=valid_token_header, + ) + assert resp.status_code == 400 + + +def test_data_sort_desc(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?dateNotification__sort=desc&page_size=5", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + dates = [row["dateNotification"] for row in body["data"] if row.get("dateNotification")] + assert dates == sorted(dates, reverse=True) +``` + +- [ ] **Step 2: Lancer les tests** + +Run: `rtk pytest tests/api/test_endpoints_data.py -v` +Expected: PASS (5 nouveaux tests, 11 au total). Si des FAIL surgissent, c'est généralement lié à l'absence d'une colonne dans `tests/test.parquet` — adapter la valeur de test à ce qui existe. + +- [ ] **Step 3: Commit** + +```bash +rtk git add tests/api/test_endpoints_data.py +rtk git commit -m "API: tests d'intégration filtres/tri/columns (#78)" +``` + +--- + +## Task 11: Suivi de consommation — compteur SQLite asynchrone + +**Files:** + +- Create: `src/api/tracking.py` +- Modify: `src/api/routes.py` +- Create: `tests/api/test_tracking.py` + +- [ ] **Step 1: Écrire le test du worker compteur** + +Créer `tests/api/test_tracking.py` : + +```python +import time + +from src.api import tokens_db, tracking + + +def test_counter_worker_increments_count(temp_db): + _, token_id = tokens_db.create_token(temp_db, "x") + + tracking.start_worker(str(temp_db)) + try: + tracking.enqueue_counter_update(token_id) + tracking.enqueue_counter_update(token_id) + # Laisser le worker drainer la queue + tracking.flush(timeout=2.0) + finally: + tracking.stop_worker() + + rows = tokens_db.list_tokens(temp_db) + assert rows[0]["count_total"] == 2 + assert rows[0]["last_used_at"] is not None + + +def test_after_request_hook_increments_counter_async( + api_client, valid_token_header +): + client, db_path = api_client + # Récupérer le token_id du token créé par la fixture + from src.api import tokens_db, tracking + + rows = tokens_db.list_tokens(db_path) + token_id = rows[0]["id"] + + # Faire une requête (qui doit déclencher l'incrément) + client.get("/api/v1/health") # pas authentifiée → ne compte pas + client.get("/api/v1/schema", headers=valid_token_header) + + tracking.flush(timeout=2.0) + + rows = tokens_db.list_tokens(db_path) + assert rows[0]["count_total"] == 1 +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_tracking.py -v` +Expected: FAIL (`src.api.tracking` n'existe pas). + +- [ ] **Step 3: Implémenter `tracking.py` (compteur seulement, Matomo arrivera Task 12)** + +Créer `src/api/tracking.py` : + +```python +import queue +import threading +from typing import Optional + +from src.api import tokens_db +from src.utils import logger + +_STOP_SENTINEL = object() +_queue: Optional[queue.Queue] = None +_worker_thread: Optional[threading.Thread] = None +_db_path: Optional[str] = None + + +def _worker_loop(q: queue.Queue, db_path: str) -> None: + while True: + item = q.get() + try: + if item is _STOP_SENTINEL: + return + kind, payload = item + if kind == "counter": + token_id = payload + try: + tokens_db.increment_usage(db_path, token_id) + except Exception: # noqa: BLE001 + logger.warning( + "tracking: échec increment_usage token_id=%s", + token_id, + exc_info=True, + ) + finally: + q.task_done() + + +def start_worker(db_path: str) -> None: + global _queue, _worker_thread, _db_path + if _worker_thread is not None and _worker_thread.is_alive(): + return + _db_path = db_path + _queue = queue.Queue() + _worker_thread = threading.Thread( + target=_worker_loop, args=(_queue, db_path), daemon=True + ) + _worker_thread.start() + + +def stop_worker() -> None: + global _worker_thread, _queue + if _worker_thread is None: + return + _queue.put(_STOP_SENTINEL) + _worker_thread.join(timeout=2.0) + _worker_thread = None + _queue = None + + +def enqueue_counter_update(token_id: int) -> None: + if _queue is None: + return # tracking désactivé (tests par ex.) + _queue.put(("counter", token_id)) + + +def flush(timeout: float = 2.0) -> None: + """Attend que la queue soit drainée. Utile en test.""" + if _queue is None: + return + _queue.join() +``` + +- [ ] **Step 4: Brancher le hook `after_request` dans `routes.py`** + +Ajouter à `src/api/routes.py` (avant la définition de `bp.route("/data")`, mais après l'init de `bp`) : + +```python +from flask import g + +from src.api import tracking + + +@bp.after_request +def _track_consumption(response): + token_id = getattr(g, "token_id", None) + if token_id is not None: + tracking.enqueue_counter_update(token_id) + return response +``` + +- [ ] **Step 5: Démarrer le worker au moment du `init_api`** + +Modifier `src/api/__init__.py` pour ajouter dans `init_api` (après `api.register_blueprint(routes.bp)`) : + +```python + import os + + from src.api import tracking + + tracking.start_worker(os.environ["USERS_DB_PATH"]) +``` + +- [ ] **Step 6: Adapter la fixture `api_client` pour démarrer/arrêter le worker proprement** + +Modifier `tests/api/conftest.py`, fixture `api_client` : + +```python +@pytest.fixture +def api_client(monkeypatch, tmp_path): + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from flask import Flask + + from src.api import init_api, tokens_db, tracking + + tokens_db.init_schema(db_path) + server = Flask(__name__) + init_api(server) + yield server.test_client(), db_path + tracking.stop_worker() +``` + +- [ ] **Step 7: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_tracking.py tests/api/test_endpoints_schema.py tests/api/test_endpoints_data.py -v` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +rtk pre-commit run --files src/api/tracking.py src/api/routes.py src/api/__init__.py tests/api/conftest.py tests/api/test_tracking.py +rtk git add src/api/tracking.py src/api/routes.py src/api/__init__.py tests/api/conftest.py tests/api/test_tracking.py +rtk git commit -m "API: compteur de consommation SQLite asynchrone (#78)" +``` + +--- + +## Task 12: Suivi de consommation — événements Matomo (fire-and-forget) + +**Files:** + +- Modify: `src/api/tracking.py` +- Modify: `src/api/routes.py` +- Modify: `tests/api/test_tracking.py` + +- [ ] **Step 1: Écrire les tests Matomo (avec mock httpx)** + +Ajouter à `tests/api/test_tracking.py` : + +```python +def test_matomo_disabled_skips_call(monkeypatch, api_client, valid_token_header): + monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "false") + client, _ = api_client + called = [] + + from src.api import tracking + + monkeypatch.setattr( + tracking, + "_post_matomo", + lambda **kw: called.append(kw), + ) + client.get("/api/v1/health") + tracking.flush(timeout=2.0) + assert called == [] + + +def test_matomo_enabled_posts_event( + monkeypatch, api_client, valid_token_header +): + monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "true") + monkeypatch.setenv("MATOMO_URL", "https://matomo.example/matomo.php") + monkeypatch.setenv("MATOMO_SITE_ID", "42") + captured = [] + + from src.api import tracking + + monkeypatch.setattr( + tracking, + "_post_matomo", + lambda **kw: captured.append(kw), + ) + + client, _ = api_client + client.get("/api/v1/schema", headers=valid_token_header) + tracking.flush(timeout=2.0) + + assert len(captured) == 1 + call = captured[0] + assert call["params"]["idsite"] == "42" + assert call["params"]["rec"] == "1" + assert "token-" in call["params"]["uid"] + assert call["params"]["dimension2"] == "200" +``` + +- [ ] **Step 2: Lancer les tests, confirmer FAIL** + +Run: `rtk pytest tests/api/test_tracking.py -v -k matomo` +Expected: FAIL (`_post_matomo` n'existe pas). + +- [ ] **Step 3: Étendre `tracking.py` avec l'envoi Matomo** + +Ajouter à `src/api/tracking.py` (en haut, après les imports existants) : + +```python +import os +import httpx +``` + +Étendre `_worker_loop` pour gérer un nouveau type `"matomo"` : + +```python +def _worker_loop(q: queue.Queue, db_path: str) -> None: + while True: + item = q.get() + try: + if item is _STOP_SENTINEL: + return + kind, payload = item + if kind == "counter": + token_id = payload + try: + tokens_db.increment_usage(db_path, token_id) + except Exception: # noqa: BLE001 + logger.warning( + "tracking: échec increment_usage token_id=%s", + token_id, + exc_info=True, + ) + elif kind == "matomo": + try: + _post_matomo(**payload) + except Exception: # noqa: BLE001 + logger.warning( + "tracking: échec envoi Matomo", exc_info=True + ) + finally: + q.task_done() + + +def _post_matomo(url: str, params: dict) -> None: + """POST fire-and-forget vers la Tracking API Matomo. Mockable en test.""" + httpx.post(url, data=params, timeout=5.0) + + +def enqueue_matomo_event( + token_id: int, + path: str, + query_string: str, + status_code: int, + user_agent: str, +) -> None: + if _queue is None: + return + if os.getenv("MATOMO_TRACKING_ENABLED", "false").lower() != "true": + return + url = os.getenv("MATOMO_URL") + site_id = os.getenv("MATOMO_SITE_ID") + if not url or not site_id: + return + full_url = f"https://decp.info{path}" + if query_string: + full_url += f"?{query_string}" + params = { + "idsite": site_id, + "rec": "1", + "url": full_url, + "action_name": f"API {path}", + "uid": f"token-{token_id}", + "dimension1": str(token_id), + "dimension2": str(status_code), + "ua": user_agent, + } + _queue.put(("matomo", {"url": url, "params": params})) +``` + +- [ ] **Step 4: Compléter le hook `after_request` pour envoyer aussi à Matomo** + +Modifier `src/api/routes.py`, la fonction `_track_consumption` : + +```python +@bp.after_request +def _track_consumption(response): + token_id = getattr(g, "token_id", None) + if token_id is not None: + tracking.enqueue_counter_update(token_id) + tracking.enqueue_matomo_event( + token_id=token_id, + path=request.path, + query_string=request.query_string.decode("utf-8", errors="replace"), + status_code=response.status_code, + user_agent=request.headers.get("User-Agent", ""), + ) + return response +``` + +- [ ] **Step 5: Lancer les tests, confirmer PASS** + +Run: `rtk pytest tests/api/test_tracking.py -v` +Expected: PASS (tous tests dont les 2 nouveaux Matomo). + +- [ ] **Step 6: Commit** + +```bash +rtk pre-commit run --files src/api/tracking.py src/api/routes.py tests/api/test_tracking.py +rtk git add src/api/tracking.py src/api/routes.py tests/api/test_tracking.py +rtk git commit -m "API: envoi Matomo fire-and-forget en async (#78)" +``` + +--- + +## Task 13: Documentation OpenAPI explicite + Changelog + +**Files:** + +- Modify: `src/api/routes.py` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Ajouter docstrings et statuts OpenAPI sur les routes** + +Modifier les routes dans `src/api/routes.py` pour enrichir Swagger. Exemple sur `/data` (ajouter en docstring) : + +```python +@bp.route("/data") +@require_token +def data(): + """Récupère des marchés publics filtrés. + + Filtres en query string sous la forme `__=`. + + Opérateurs : exact, contains, notcontains, less, greater, + strictly_less, strictly_greater, in, notin, isnull, isnotnull, sort. + + Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000), + columns (csv), count (true|false ; mettre false pour économiser le COUNT(*)). + """ + ... # corps inchangé +``` + +Faire de même pour `/schema` et `/health` (une ligne suffit). + +- [ ] **Step 2: Vérifier manuellement la doc Swagger** + +Run: `rtk python run.py &` puis ouvrir dans un navigateur. +Expected: Swagger UI affiche les 3 endpoints, doc lisible. + +Arrêter le serveur (Ctrl-C ou `kill %1`). + +- [ ] **Step 3: Ajouter l'entrée Changelog** + +Lire `CHANGELOG.md` pour comprendre le format puis ajouter en haut, sous la section "Unreleased" (ou créer cette section) : + +```markdown +## [Unreleased] + +### Ajouté + +- API privée tabulaire `/api/v1/data` (filtres dynamiques, pagination, tri). +- Endpoint `/api/v1/schema` (description du dataset). +- Endpoint `/api/v1/health` (sonde monitoring). +- Documentation interactive Swagger UI à `/api/v1/swagger`. +- CLI d'administration des tokens : `python -m src.api.tokens_cli` (create, list, revoke). +- Suivi de consommation : compteurs SQLite (`api_tokens.count_total`, `last_used_at`) + événements Matomo async. + +### Configuration + +- Nouvelles variables d'environnement : `USERS_DB_PATH`, `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TRACKING_ENABLED`. +- Création de 2 Custom Dimensions côté Matomo : `dimension1=token_id`, `dimension2=http_status`. +``` + +- [ ] **Step 4: Lancer toute la suite de tests** + +Run: `rtk pytest tests/api/ -v` +Expected: tous les tests PASS. + +Run: `rtk pytest tests/ -x` (suite complète, y compris l'app Dash) +Expected: pas de régression (la suite Dash existante peut être longue, c'est normal). + +- [ ] **Step 5: Commit** + +```bash +rtk pre-commit run --files src/api/routes.py CHANGELOG.md +rtk git add src/api/routes.py CHANGELOG.md +rtk git commit -m "API: documentation OpenAPI et CHANGELOG (#78)" +``` + +--- + +## Task 14: Mention de l'API sur la page À propos + +**Files:** + +- Modify: `src/pages/a_propos.py` (vérifier le nom exact) + +- [ ] **Step 1: Repérer la page À propos** + +Run: `rtk ls src/pages/` +Identifier le fichier correspondant à `/a-propos` (probablement `a_propos.py` ou `apropos.py`). + +- [ ] **Step 2: Ajouter une section "API"** + +Dans le layout de la page, ajouter une section markdown ou un bloc HTML : + +```python +dcc.Markdown(""" +### API privée + +Une API HTTP est disponible pour accéder aux mêmes données par programme. +Documentation interactive : [Swagger UI](/api/v1/swagger). + +L'accès se fait sur token. Pour en obtenir un, contactez +[colin@maudry.com](mailto:colin@maudry.com). +"""), +``` + +- [ ] **Step 3: Vérifier visuellement** + +Run: `rtk python run.py &` +Ouvrir et confirmer que la section apparaît. +Arrêter le serveur. + +- [ ] **Step 4: Commit** + +```bash +rtk pre-commit run --files src/pages/a_propos.py +rtk git add src/pages/a_propos.py +rtk git commit -m "API: mention sur la page À propos (#78)" +``` + +--- + +## Notes pour l'exécution + +- **Couverture spec** : chaque section du spec a une ou plusieurs tâches associées (deps → Task 1, tokens_db → Task 2, CLI → Task 3, filters → Task 4, auth → Task 5, blueprint+health → Task 6, branchement → Task 7, schema → Task 8, data → Tasks 9-10, tracking SQLite → Task 11, Matomo → Task 12, OpenAPI+changelog → Task 13). +- **`tests/test.parquet`** : peut ne pas contenir toutes les colonnes citées dans les tests (cf. CLAUDE.md). Si un test échoue parce qu'une colonne n'existe pas dans la fixture, l'adapter à une colonne effectivement présente. +- **`src/db.py:127`** ouvre une connexion DuckDB read-only au moment de l'import. Cela peut être lent au premier lancement de la suite de tests (rebuild de la base). C'est normal. +- **Worker tracking** : démarré dans `init_api`, démon. Pas de cleanup nécessaire en prod (mourra avec le process). En test, la fixture `api_client` appelle `stop_worker()` pour propreté. +- **Git flow** : on est déjà sur `feature/78_api`. Tous les commits y vont. Pas de `git push` (cf. CLAUDE.md). From bc3b09e2a9b687a52b6579b596a564b3ce5f3869 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:04:09 +0200 Subject: [PATCH 04/34] =?UTF-8?q?API:=20d=C3=A9pendances=20et=20squelette?= =?UTF-8?q?=20de=20package=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .template.env | 4 ++++ pyproject.toml | 4 ++++ src/api/__init__.py | 1 + tests/api/__init__.py | 0 4 files changed, 9 insertions(+) create mode 100644 src/api/__init__.py create mode 100644 tests/api/__init__.py diff --git a/.template.env b/.template.env index 6ac9a91..357e16e 100644 --- a/.template.env +++ b/.template.env @@ -24,3 +24,7 @@ TO_EMAIL="to@example.com" # adresse de destination des emails (To) MATOMO_ID_SITE= MATOMO_BASE_URL= MATOMO_TOKEN= + +# API privée +USERS_DB_PATH=./users.sqlite +MATOMO_TRACKING_ENABLED=true diff --git a/pyproject.toml b/pyproject.toml index a1e0f52..a73e0fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ "flask-caching", "pyarrow>=23.0.1", "flask-cors>=6.0.2", + "flask-smorest>=0.46.0", + "marshmallow>=3.20.0", ] [dependency-groups] @@ -43,5 +45,7 @@ env = [ "DEVELOPMENT=true", "REBUILD_DUCKDB=true", "DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json", + "USERS_DB_PATH=tests/users.test.sqlite", + "MATOMO_TRACKING_ENABLED=false", ] addopts = "-p no:warnings" diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..fff577c --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1 @@ +# decp.info — API privée. init_api(server) sera ajouté Task 6. diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 From 1d190e40866675e0b99569dc4b887166742cfe51 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:05:58 +0200 Subject: [PATCH 05/34] API: ajouter MATOMO_URL et MATOMO_SITE_ID dans .template.env (#78) --- .template.env | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.template.env b/.template.env index 357e16e..aef7230 100644 --- a/.template.env +++ b/.template.env @@ -27,4 +27,6 @@ MATOMO_TOKEN= # API privée USERS_DB_PATH=./users.sqlite +MATOMO_URL=https://analytics.maudry.com/matomo.php +MATOMO_SITE_ID=14 MATOMO_TRACKING_ENABLED=true From ca44b99016c9ef4373db21b1f59bb8c64f18fab3 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:19:50 +0200 Subject: [PATCH 06/34] API: tokens_db SQLite CRUD (#78) --- src/api/tokens_db.py | 94 +++++++++++++++++++++++++++++++++++++ tests/api/conftest.py | 12 +++++ tests/api/test_tokens_db.py | 64 +++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/api/tokens_db.py create mode 100644 tests/api/conftest.py create mode 100644 tests/api/test_tokens_db.py diff --git a/src/api/tokens_db.py b/src/api/tokens_db.py new file mode 100644 index 0000000..76ea1f0 --- /dev/null +++ b/src/api/tokens_db.py @@ -0,0 +1,94 @@ +import hashlib +import secrets +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +TOKEN_PREFIX = "decpinfo_" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + user_id INTEGER, + created_at TEXT NOT NULL, + last_used_at TEXT, + count_total INTEGER NOT NULL DEFAULT 0, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); +""" + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +@contextmanager +def _connect(db_path): + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +def init_schema(db_path) -> None: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + with _connect(db_path) as conn: + conn.executescript(SCHEMA) + conn.commit() + + +def create_token(db_path, label: str, user_id: int | None = None) -> tuple[str, int]: + token = TOKEN_PREFIX + secrets.token_hex(32) + with _connect(db_path) as conn: + cur = conn.execute( + "INSERT INTO api_tokens (token_hash, label, user_id, created_at) " + "VALUES (?, ?, ?, ?)", + (_hash(token), label, user_id, _utcnow_iso()), + ) + conn.commit() + return token, cur.lastrowid + + +def get_token_by_plaintext(db_path, token: str) -> dict | None: + with _connect(db_path) as conn: + row = conn.execute( + "SELECT * FROM api_tokens WHERE token_hash = ?", + (_hash(token),), + ).fetchone() + return dict(row) if row else None + + +def revoke_token(db_path, token_id: int) -> None: + with _connect(db_path) as conn: + conn.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE id = ?", + (_utcnow_iso(), token_id), + ) + conn.commit() + + +def increment_usage(db_path, token_id: int) -> None: + with _connect(db_path) as conn: + conn.execute( + "UPDATE api_tokens " + "SET count_total = count_total + 1, last_used_at = ? " + "WHERE id = ?", + (_utcnow_iso(), token_id), + ) + conn.commit() + + +def list_tokens(db_path) -> list[dict]: + with _connect(db_path) as conn: + rows = conn.execute("SELECT * FROM api_tokens ORDER BY id").fetchall() + return [dict(r) for r in rows] diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..7baca3f --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,12 @@ +import pytest + + +@pytest.fixture +def temp_db(tmp_path, monkeypatch): + """Une SQLite éphémère pour les tests qui modifient la DB.""" + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from src.api import tokens_db + + tokens_db.init_schema(db_path) + return db_path diff --git a/tests/api/test_tokens_db.py b/tests/api/test_tokens_db.py new file mode 100644 index 0000000..91ca0b0 --- /dev/null +++ b/tests/api/test_tokens_db.py @@ -0,0 +1,64 @@ +import sqlite3 + +from src.api import tokens_db + + +def test_init_schema_creates_table(temp_db): + with sqlite3.connect(str(temp_db)) as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'" + ).fetchall() + assert rows == [("api_tokens",)] + + +def test_create_token_returns_plaintext_and_stores_hash(temp_db): + token, token_id = tokens_db.create_token(temp_db, "test-label") + assert token.startswith("decpinfo_") + assert len(token) == len("decpinfo_") + 64 # 32 octets hex = 64 chars + assert token_id >= 1 + + with sqlite3.connect(str(temp_db)) as conn: + row = conn.execute( + "SELECT token_hash, label, count_total FROM api_tokens WHERE id = ?", + (token_id,), + ).fetchone() + assert row[1] == "test-label" + assert row[2] == 0 + assert row[0] != token # stocké en clair impossible + assert len(row[0]) == 64 # sha256 hex + + +def test_get_token_by_plaintext_returns_row(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row is not None + assert row["id"] == token_id + assert row["label"] == "x" + assert row["revoked_at"] is None + + +def test_get_token_unknown_returns_none(temp_db): + assert tokens_db.get_token_by_plaintext(temp_db, "decpinfo_zzz") is None + + +def test_revoke_token_sets_revoked_at(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.revoke_token(temp_db, token_id) + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row["revoked_at"] is not None + + +def test_increment_usage_updates_counter_and_timestamp(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.increment_usage(temp_db, token_id) + tokens_db.increment_usage(temp_db, token_id) + row = tokens_db.get_token_by_plaintext(temp_db, token) + assert row["count_total"] == 2 + assert row["last_used_at"] is not None + + +def test_list_tokens_returns_all(temp_db): + tokens_db.create_token(temp_db, "a") + tokens_db.create_token(temp_db, "b") + rows = tokens_db.list_tokens(temp_db) + assert [r["label"] for r in rows] == ["a", "b"] From 4b67cde1478cef6b657e4f2878f4886a26496cc1 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:34:05 +0200 Subject: [PATCH 07/34] API: CLI tokens_cli (create/list/revoke) (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/tokens_cli.py | 57 ++++++++++++++++++++++++++++++++++++ tests/api/test_tokens_cli.py | 33 +++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/api/tokens_cli.py create mode 100644 tests/api/test_tokens_cli.py diff --git a/src/api/tokens_cli.py b/src/api/tokens_cli.py new file mode 100644 index 0000000..287ac06 --- /dev/null +++ b/src/api/tokens_cli.py @@ -0,0 +1,57 @@ +import argparse +import os +import sys + +from src.api import tokens_db + + +def main(argv=None, env=None) -> int: + env = env if env is not None else os.environ + parser = argparse.ArgumentParser(prog="python -m src.api.tokens_cli") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_create = sub.add_parser("create", help="Créer un token API") + p_create.add_argument("--label", required=True) + p_create.add_argument("--user-id", type=int, default=None) + + sub.add_parser("list", help="Lister les tokens") + + p_revoke = sub.add_parser("revoke", help="Révoquer un token") + p_revoke.add_argument("token_id", type=int) + + args = parser.parse_args(argv) + db_path = env["USERS_DB_PATH"] + tokens_db.init_schema(db_path) + + if args.cmd == "create": + token, token_id = tokens_db.create_token(db_path, args.label, args.user_id) + print(f"id={token_id} label={args.label}") + print(f"token (à conserver, ne sera plus affiché) : {token}") + return 0 + + if args.cmd == "list": + rows = tokens_db.list_tokens(db_path) + if not rows: + print("(aucun token)") + return 0 + print( + f"{'id':<4} {'label':<40} {'created_at':<26} {'last_used_at':<26} {'count':<7} revoked" + ) + for r in rows: + print( + f"{r['id']:<4} {r['label']:<40} {r['created_at']:<26} " + f"{(r['last_used_at'] or '-'):<26} {r['count_total']:<7} " + f"{r['revoked_at'] or ''}" + ) + return 0 + + if args.cmd == "revoke": + tokens_db.revoke_token(db_path, args.token_id) + print(f"token id={args.token_id} révoqué") + return 0 + + return 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/tests/api/test_tokens_cli.py b/tests/api/test_tokens_cli.py new file mode 100644 index 0000000..24b7d87 --- /dev/null +++ b/tests/api/test_tokens_cli.py @@ -0,0 +1,33 @@ +from src.api import tokens_cli, tokens_db + + +def _run(args, env): + return tokens_cli.main(args, env=env) + + +def test_create_prints_plaintext_token_once(temp_db, capsys): + rc = _run(["create", "--label", "alice"], env={"USERS_DB_PATH": str(temp_db)}) + out = capsys.readouterr().out + assert rc == 0 + assert "decpinfo_" in out + tokens = tokens_db.list_tokens(temp_db) + assert len(tokens) == 1 + assert tokens[0]["label"] == "alice" + + +def test_list_shows_tokens(temp_db, capsys): + tokens_db.create_token(temp_db, "alice") + tokens_db.create_token(temp_db, "bob") + rc = _run(["list"], env={"USERS_DB_PATH": str(temp_db)}) + out = capsys.readouterr().out + assert rc == 0 + assert "alice" in out + assert "bob" in out + + +def test_revoke_sets_revoked_at(temp_db, capsys): + _, token_id = tokens_db.create_token(temp_db, "alice") + rc = _run(["revoke", str(token_id)], env={"USERS_DB_PATH": str(temp_db)}) + assert rc == 0 + tokens = tokens_db.list_tokens(temp_db) + assert tokens[0]["revoked_at"] is not None From a3cfad3d8fcc3cd1cb91f6bb6c248cdd7561142f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:45:55 +0200 Subject: [PATCH 08/34] =?UTF-8?q?API:=20parser=20de=20filtres=20et=20g?= =?UTF-8?q?=C3=A9n=C3=A9ration=20SQL=20param=C3=A9tr=C3=A9=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/api/filters.py | 138 +++++++++++++++++++++++++++++++++++++ tests/api/test_filters.py | 141 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/api/filters.py create mode 100644 tests/api/test_filters.py diff --git a/src/api/filters.py b/src/api/filters.py new file mode 100644 index 0000000..573dde8 --- /dev/null +++ b/src/api/filters.py @@ -0,0 +1,138 @@ +from datetime import date, datetime + +import polars as pl + +OPERATORS = { + "exact", + "contains", + "notcontains", + "less", + "greater", + "strictly_less", + "strictly_greater", + "in", + "notin", + "isnull", + "isnotnull", + "sort", +} + +RESERVED_PARAMS = {"page", "page_size", "columns", "count"} + + +class FilterError(ValueError): + def __init__(self, message: str, field: str | None = None): + super().__init__(message) + self.field = field + + +def _coerce(value: str, dtype: pl.DataType, key: str): + if dtype == pl.String: + return value + if dtype.is_integer(): + try: + return int(value) + except ValueError: + raise FilterError(f"Valeur entière attendue, reçu {value!r}", field=key) + if dtype.is_float(): + try: + return float(value) + except ValueError: + raise FilterError(f"Valeur décimale attendue, reçu {value!r}", field=key) + if dtype == pl.Date: + try: + return date.fromisoformat(value) + except ValueError: + raise FilterError( + f"Date ISO 8601 attendue (YYYY-MM-DD), reçu {value!r}", field=key + ) + if dtype == pl.Datetime: + try: + return datetime.fromisoformat(value) + except ValueError: + raise FilterError(f"Datetime ISO 8601 attendu, reçu {value!r}", field=key) + return value + + +def _split_key(key: str) -> tuple[str, str] | None: + if "__" not in key: + return None + col, _, op = key.rpartition("__") + if not col or not op: + return None + return col, op + + +def build_where( + args: list[tuple[str, str]], schema: pl.Schema +) -> tuple[str, list, str | None]: + """Parse query params into (where_sql, params, order_by_sql). + + args: list of (key, value) tuples preserving URL order (Werkzeug MultiDict + preserves insertion order on `request.args.items(multi=True)`). + """ + where_parts: list[str] = [] + params: list = [] + order_parts: list[str] = [] + + for key, value in args: + if key in RESERVED_PARAMS: + continue + + parsed = _split_key(key) + if not parsed: + raise FilterError(f"Paramètre non reconnu : {key}", field=key) + col, op = parsed + + if op not in OPERATORS: + raise FilterError(f"Opérateur inconnu : __{op}", field=key) + + if col not in schema: + raise FilterError(f"Colonne inconnue : {col!r}", field=key) + + if op == "sort": + direction = value.lower() + if direction not in ("asc", "desc"): + raise FilterError( + f"Tri attendu 'asc' ou 'desc', reçu {value!r}", field=key + ) + order_parts.append(f'"{col}" {direction.upper()}') + continue + + if op in ("isnull", "isnotnull"): + sql = "IS NULL" if op == "isnull" else "IS NOT NULL" + where_parts.append(f'"{col}" {sql}') + continue + + dtype = schema[col] + + if op in ("in", "notin"): + values = [_coerce(v.strip(), dtype, key) for v in value.split(",")] + placeholders = ",".join(["?"] * len(values)) + sql_op = "IN" if op == "in" else "NOT IN" + where_parts.append(f'"{col}" {sql_op} ({placeholders})') + params.extend(values) + continue + + v = _coerce(value, dtype, key) + + op_sql = { + "exact": "=", + "less": "<=", + "greater": ">=", + "strictly_less": "<", + "strictly_greater": ">", + } + if op in op_sql: + where_parts.append(f'"{col}" {op_sql[op]} ?') + params.append(v) + elif op == "contains": + where_parts.append(f'"{col}" LIKE ?') + params.append(f"%{v}%") + elif op == "notcontains": + where_parts.append(f'"{col}" NOT LIKE ?') + params.append(f"%{v}%") + + where_sql = " AND ".join(where_parts) if where_parts else "TRUE" + order_sql = ", ".join(order_parts) if order_parts else None + return where_sql, params, order_sql diff --git a/tests/api/test_filters.py b/tests/api/test_filters.py new file mode 100644 index 0000000..b7706fe --- /dev/null +++ b/tests/api/test_filters.py @@ -0,0 +1,141 @@ +import polars as pl +import pytest + +from src.api.filters import FilterError, build_where + +SCHEMA = pl.Schema( + { + "uid": pl.String, + "objet": pl.String, + "montant": pl.Float64, + "annee": pl.Int64, + "dateNotification": pl.Date, + } +) + + +def test_no_filters_returns_true(): + where, params, order = build_where([], SCHEMA) + assert where == "TRUE" + assert params == [] + assert order is None + + +def test_exact_filter(): + where, params, _ = build_where([("uid__exact", "abc")], SCHEMA) + assert where == '"uid" = ?' + assert params == ["abc"] + + +def test_contains_filter_uses_like_wildcards(): + where, params, _ = build_where([("objet__contains", "informatique")], SCHEMA) + assert where == '"objet" LIKE ?' + assert params == ["%informatique%"] + + +def test_notcontains_filter(): + where, params, _ = build_where([("objet__notcontains", "x")], SCHEMA) + assert where == '"objet" NOT LIKE ?' + assert params == ["%x%"] + + +def test_comparison_operators_on_int(): + where, params, _ = build_where([("annee__strictly_greater", "2023")], SCHEMA) + assert where == '"annee" > ?' + assert params == [2024 - 1] # int coercion: 2023 + + +def test_in_filter_splits_on_commas(): + where, params, _ = build_where([("annee__in", "2022,2023,2024")], SCHEMA) + assert where == '"annee" IN (?,?,?)' + assert params == [2022, 2023, 2024] + + +def test_notin_filter(): + where, params, _ = build_where([("annee__notin", "2020,2021")], SCHEMA) + assert where == '"annee" NOT IN (?,?)' + assert params == [2020, 2021] + + +def test_isnull_filter_ignores_value(): + where, params, _ = build_where([("dateNotification__isnull", "anything")], SCHEMA) + assert where == '"dateNotification" IS NULL' + assert params == [] + + +def test_isnotnull_filter(): + where, params, _ = build_where([("dateNotification__isnotnull", "")], SCHEMA) + assert where == '"dateNotification" IS NOT NULL' + + +def test_multiple_filters_joined_by_and(): + where, params, _ = build_where( + [("uid__exact", "a"), ("annee__greater", "2020")], SCHEMA + ) + assert where == '"uid" = ? AND "annee" >= ?' + assert params == ["a", 2020] + + +def test_unknown_column_raises(): + with pytest.raises(FilterError) as exc: + build_where([("foo__exact", "bar")], SCHEMA) + assert "foo" in str(exc.value) + assert exc.value.field == "foo__exact" + + +def test_unknown_operator_raises(): + with pytest.raises(FilterError) as exc: + build_where([("uid__weird", "x")], SCHEMA) + assert "weird" in str(exc.value) + + +def test_bad_int_value_raises(): + with pytest.raises(FilterError): + build_where([("annee__exact", "notanint")], SCHEMA) + + +def test_bad_date_value_raises(): + with pytest.raises(FilterError): + build_where([("dateNotification__exact", "notadate")], SCHEMA) + + +def test_date_iso_coercion(): + where, params, _ = build_where( + [("dateNotification__greater", "2024-01-01")], SCHEMA + ) + from datetime import date + + assert params == [date(2024, 1, 1)] + + +def test_reserved_params_are_ignored(): + where, params, order = build_where( + [ + ("page", "2"), + ("page_size", "100"), + ("columns", "uid"), + ("count", "false"), + ("uid__exact", "z"), + ], + SCHEMA, + ) + assert where == '"uid" = ?' + assert params == ["z"] + + +def test_sort_returns_order_by(): + where, params, order = build_where( + [("annee__sort", "desc"), ("uid__sort", "asc")], SCHEMA + ) + assert where == "TRUE" + assert order == '"annee" DESC, "uid" ASC' + + +def test_sort_invalid_direction_raises(): + with pytest.raises(FilterError): + build_where([("uid__sort", "sideways")], SCHEMA) + + +def test_param_without_operator_raises(): + with pytest.raises(FilterError): + build_where([("uidexact", "x")], SCHEMA) From f12ada2fac81ac2638deecbe4bed9bc9764b4165 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:49:01 +0200 Subject: [PATCH 09/34] =?UTF-8?q?API:=20d=C3=A9corateur=20@require=5Ftoken?= =?UTF-8?q?=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth.py | 32 +++++++++++++++++++++++ tests/api/test_auth.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/api/auth.py create mode 100644 tests/api/test_auth.py diff --git a/src/api/auth.py b/src/api/auth.py new file mode 100644 index 0000000..1985684 --- /dev/null +++ b/src/api/auth.py @@ -0,0 +1,32 @@ +import os +from functools import wraps + +from flask import abort, g, jsonify, make_response, request + +from src.api import tokens_db + + +def _abort_401(message: str): + resp = make_response(jsonify({"message": message}), 401) + abort(resp) + + +def require_token(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + _abort_401("missing_token") + token = header[len("Bearer ") :].strip() + if not token: + _abort_401("missing_token") + db_path = os.environ["USERS_DB_PATH"] + row = tokens_db.get_token_by_plaintext(db_path, token) + if row is None: + _abort_401("invalid_token") + if row["revoked_at"] is not None: + _abort_401("revoked_token") + g.token_id = row["id"] + return fn(*args, **kwargs) + + return wrapper diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..1584bed --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,59 @@ +from flask import Flask, g, jsonify + +from src.api import tokens_db +from src.api.auth import require_token + + +def _make_app(): + app = Flask(__name__) + + @app.route("/protected") + @require_token + def protected(): + return jsonify({"token_id": g.token_id}) + + return app + + +def test_missing_header_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get("/protected") + assert resp.status_code == 401 + assert resp.get_json()["message"] == "missing_token" + + +def test_bearer_without_value_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get("/protected", headers={"Authorization": "Bearer "}) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "missing_token" + + +def test_invalid_token_returns_401(temp_db): + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": "Bearer decpinfo_unknown"} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "invalid_token" + + +def test_revoked_token_returns_401(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + tokens_db.revoke_token(temp_db, token_id) + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": f"Bearer {token}"} + ) + assert resp.status_code == 401 + assert resp.get_json()["message"] == "revoked_token" + + +def test_valid_token_sets_g_and_calls_view(temp_db): + token, token_id = tokens_db.create_token(temp_db, "x") + app = _make_app() + resp = app.test_client().get( + "/protected", headers={"Authorization": f"Bearer {token}"} + ) + assert resp.status_code == 200 + assert resp.get_json()["token_id"] == token_id From f24d0ba168f98267c11b13a788839703c37985af Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 13:52:06 +0200 Subject: [PATCH 10/34] API: blueprint flask-smorest + endpoint /health (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/__init__.py | 21 ++++++++++++++++++++- src/api/routes.py | 14 ++++++++++++++ tests/api/test_health.py | 16 ++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/api/routes.py create mode 100644 tests/api/test_health.py diff --git a/src/api/__init__.py b/src/api/__init__.py index fff577c..4dd2e5b 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -1 +1,20 @@ -# decp.info — API privée. init_api(server) sera ajouté Task 6. +from flask_smorest import Api + +from src.api import routes + + +def init_api(server) -> None: + """Enregistre le blueprint d'API privée sur le serveur Flask.""" + server.config.setdefault("API_TITLE", "decp.info API") + server.config.setdefault("API_VERSION", "v1") + server.config.setdefault("OPENAPI_VERSION", "3.0.3") + server.config.setdefault("OPENAPI_URL_PREFIX", "/api/v1") + server.config.setdefault("OPENAPI_JSON_PATH", "openapi.json") + server.config.setdefault("OPENAPI_SWAGGER_UI_PATH", "swagger") + server.config.setdefault( + "OPENAPI_SWAGGER_UI_URL", + "https://cdn.jsdelivr.net/npm/swagger-ui-dist/", + ) + + api = Api(server) + api.register_blueprint(routes.bp) diff --git a/src/api/routes.py b/src/api/routes.py new file mode 100644 index 0000000..759ff1b --- /dev/null +++ b/src/api/routes.py @@ -0,0 +1,14 @@ +from flask_smorest import Blueprint + +bp = Blueprint( + "api_v1", + "api_v1", + url_prefix="/api/v1", + description="API privée decp.info — accès tabulaire aux marchés publics.", +) + + +@bp.route("/health") +def health(): + """Sonde de santé, sans authentification.""" + return {"status": "ok"} diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000..3bddbfd --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,16 @@ +from flask import Flask + +from src.api import init_api + + +def _make_app(): + app = Flask(__name__) + init_api(app) + return app + + +def test_health_returns_ok_without_auth(): + app = _make_app() + resp = app.test_client().get("/api/v1/health") + assert resp.status_code == 200 + assert resp.get_json() == {"status": "ok"} From 686218c63946793da9fd506bf0759b663e33fc77 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:07:47 +0200 Subject: [PATCH 11/34] API: branchement init_api dans l'app Dash (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/app.py | 4 ++++ tests/api/test_health.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/app.py b/src/app.py index 40a40d5..2db5ff3 100644 --- a/src/app.py +++ b/src/app.py @@ -51,6 +51,10 @@ cache.init_app( }, ) +from src.api import init_api # noqa: E402 # inline: src.db.conn must be ready first + +init_api(app.server) + # robots.txt @app.server.route("/robots.txt") diff --git a/tests/api/test_health.py b/tests/api/test_health.py index 3bddbfd..7401a1c 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -14,3 +14,11 @@ def test_health_returns_ok_without_auth(): resp = app.test_client().get("/api/v1/health") assert resp.status_code == 200 assert resp.get_json() == {"status": "ok"} + + +def test_health_via_real_app(): + """Vérifie que init_api est bien branché dans src.app.""" + from src.app import app as dash_app + + resp = dash_app.server.test_client().get("/api/v1/health") + assert resp.status_code == 200 From a05c869b0db876dad914e6401db3e6f0f1c1752d Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:08:30 +0200 Subject: [PATCH 12/34] =?UTF-8?q?API:=20renforcer=20test=20int=C3=A9gratio?= =?UTF-8?q?n=20/health=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/api/test_health.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/test_health.py b/tests/api/test_health.py index 7401a1c..ca4f540 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -22,3 +22,4 @@ def test_health_via_real_app(): resp = dash_app.server.test_client().get("/api/v1/health") assert resp.status_code == 200 + assert resp.get_json() == {"status": "ok"} From 6f4e7f1853dbf6c61e36aa272490e595e34a42e9 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:10:20 +0200 Subject: [PATCH 13/34] API: endpoint /schema (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.py | 11 +++++++++++ tests/api/conftest.py | 24 ++++++++++++++++++++++++ tests/api/test_endpoints_schema.py | 19 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 tests/api/test_endpoints_schema.py diff --git a/src/api/routes.py b/src/api/routes.py index 759ff1b..a4728b3 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,5 +1,8 @@ from flask_smorest import Blueprint +from src.api.auth import require_token +from src.db import schema as duckdb_schema + bp = Blueprint( "api_v1", "api_v1", @@ -12,3 +15,11 @@ bp = Blueprint( def health(): """Sonde de santé, sans authentification.""" return {"status": "ok"} + + +@bp.route("/schema") +@require_token +def schema(): + """Liste des colonnes disponibles dans le dataset DECP.""" + cols = [{"name": name, "type": str(dtype)} for name, dtype in duckdb_schema.items()] + return {"columns": cols} diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 7baca3f..67dc109 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -10,3 +10,27 @@ def temp_db(tmp_path, monkeypatch): tokens_db.init_schema(db_path) return db_path + + +@pytest.fixture +def api_client(monkeypatch, tmp_path): + """Client Flask test avec USERS_DB_PATH éphémère et blueprint API monté.""" + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from flask import Flask + + from src.api import init_api, tokens_db + + tokens_db.init_schema(db_path) + server = Flask(__name__) + init_api(server) + return server.test_client(), db_path + + +@pytest.fixture +def valid_token_header(api_client): + from src.api import tokens_db + + _, db_path = api_client + token, _ = tokens_db.create_token(db_path, "test-token") + return {"Authorization": f"Bearer {token}"} diff --git a/tests/api/test_endpoints_schema.py b/tests/api/test_endpoints_schema.py new file mode 100644 index 0000000..3d8438e --- /dev/null +++ b/tests/api/test_endpoints_schema.py @@ -0,0 +1,19 @@ +def test_schema_without_token_returns_401(api_client): + client, _ = api_client + resp = client.get("/api/v1/schema") + assert resp.status_code == 401 + + +def test_schema_returns_columns(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/schema", headers=valid_token_header) + assert resp.status_code == 200 + data = resp.get_json() + assert "columns" in data + assert isinstance(data["columns"], list) + assert len(data["columns"]) > 0 + first = data["columns"][0] + assert set(first.keys()) >= {"name", "type"} + # uid doit être présent dans le schéma DECP de test + names = [c["name"] for c in data["columns"]] + assert "uid" in names From 7571b9a9845ab37a89b2819ff35b46edd235afe8 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:12:27 +0200 Subject: [PATCH 14/34] API: script de benchmark tests/benchmark_api.py (#78) Co-Authored-By: Claude Sonnet 4.6 --- tests/benchmark_api.py | 208 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/benchmark_api.py diff --git a/tests/benchmark_api.py b/tests/benchmark_api.py new file mode 100644 index 0000000..9431342 --- /dev/null +++ b/tests/benchmark_api.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python +"""Benchmark de l'endpoint /data de l'API privée decp.info. + +Usage : + python tests/benchmark_api.py --url http://localhost:8050/api/v1/data --token decpinfo_xxx + python tests/benchmark_api.py --url https://decp.info/api/v1/data --token decpinfo_xxx --runs 20 + +Le script mesure le temps de réponse de chaque scénario et affiche un tableau +de statistiques (min / médiane / p95 / max). +""" + +import argparse +import statistics +import sys +import time + +import httpx + +SCENARIOS: list[dict] = [ + { + "name": "sans filtre (page 1, 50 résultats)", + "params": {"page": 1, "page_size": 50}, + }, + { + "name": "filtre __exact sur département", + "params": {"acheteur_departement_code__exact": "44", "page_size": 50}, + }, + { + "name": "filtre __contains sur objet", + "params": {"objet__contains": "informatique", "page_size": 50}, + }, + { + "name": "filtre __greater sur date", + "params": {"dateNotification__greater": "2024-01-01", "page_size": 50}, + }, + { + "name": "filtre __strictly_greater sur montant", + "params": {"montant__strictly_greater": "100000", "page_size": 50}, + }, + { + "name": "filtre __in (CPV multiples)", + "params": {"cpv_8__in": "72000000,72200000", "page_size": 50}, + }, + { + "name": "filtre __isnull", + "params": {"montant__isnull": "", "page_size": 50}, + }, + { + "name": "tri desc + colonnes sélectionnées", + "params": { + "dateNotification__sort": "desc", + "columns": "uid,objet,montant,dateNotification", + "page_size": 50, + }, + }, + { + "name": "filtres combinés", + "params": { + "acheteur_departement_code__exact": "75", + "dateNotification__greater": "2023-01-01", + "montant__strictly_greater": "50000", + "dateNotification__sort": "desc", + "page_size": 50, + }, + }, + { + "name": "count=false (économise COUNT(*))", + "params": {"page_size": 50, "count": "false"}, + }, + { + "name": "page 2", + "params": {"page": 2, "page_size": 50}, + }, +] + +COL_NAME = 44 +COL_STATUS = 8 +COL_STAT = 10 + + +def percentile(data: list[float], p: float) -> float: + if not data: + return float("nan") + sorted_data = sorted(data) + k = (len(sorted_data) - 1) * p / 100 + lo, hi = int(k), min(int(k) + 1, len(sorted_data) - 1) + return sorted_data[lo] + (sorted_data[hi] - sorted_data[lo]) * (k - lo) + + +def run_benchmark(base_url: str, token: str | None, runs: int) -> None: + auth_header = {"Authorization": f"Bearer {token}"} if token else {} + results: list[dict] = [] + + print(f"\nBenchmark {base_url}") + print(f"Scénarios : {len(SCENARIOS)} | Répétitions : {runs}\n") + + for scenario in SCENARIOS: + timings: list[float] = [] + last_status = 0 + + for _ in range(runs): + headers = auth_header + url = base_url + params = scenario["params"] + + try: + t0 = time.perf_counter() + resp = httpx.get(url, params=params, headers=headers, timeout=30) + elapsed = (time.perf_counter() - t0) * 1000 + last_status = resp.status_code + timings.append(elapsed) + except httpx.RequestError as exc: + print(f" ERREUR réseau : {exc}") + last_status = 0 + break + + if timings: + results.append( + { + "name": scenario["name"], + "status": last_status, + "min": min(timings), + "median": percentile(timings, 50), + "p95": percentile(timings, 95), + "max": max(timings), + "mean": statistics.mean(timings), + "runs": len(timings), + } + ) + status_str = f"[{last_status}]" + print( + f" {scenario['name'][:COL_NAME]:<{COL_NAME}}" + f" {status_str:<{COL_STATUS}}" + f" médiane {results[-1]['median']:>7.1f} ms" + f" p95 {results[-1]['p95']:>7.1f} ms" + ) + else: + print(f" {scenario['name'][:COL_NAME]:<{COL_NAME}} ÉCHEC") + + _print_summary(results) + + +def _print_summary(results: list[dict]) -> None: + if not results: + return + + sep = "-" * (COL_NAME + COL_STATUS + 4 * (COL_STAT + 3) + 6) + header = ( + f"\n{'Scénario':<{COL_NAME}} {'Status':<{COL_STATUS}}" + f" {'Min (ms)':>{COL_STAT}}" + f" {'Médiane (ms)':>{COL_STAT}}" + f" {'P95 (ms)':>{COL_STAT}}" + f" {'Max (ms)':>{COL_STAT}}" + ) + print(f"\n{'=' * len(sep)}") + print("RÉSUMÉ") + print(f"{'=' * len(sep)}") + print(header) + print(sep) + + for r in results: + status_str = f"[{r['status']}]" + print( + f"{r['name'][:COL_NAME]:<{COL_NAME}}" + f" {status_str:<{COL_STATUS}}" + f" {r['min']:>{COL_STAT}.1f}" + f" {r['median']:>{COL_STAT}.1f}" + f" {r['p95']:>{COL_STAT}.1f}" + f" {r['max']:>{COL_STAT}.1f}" + ) + + print(sep) + medians = [r["median"] for r in results] + print( + f"\nMédiane globale : {statistics.mean(medians):.1f} ms" + f" | Pire p95 : {max(r['p95'] for r in results):.1f} ms" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Benchmark de l'API privée decp.info") + parser.add_argument( + "--url", + default="http://localhost:8050/api/v1/data", + help="URL complète de l'endpoint /data (défaut : http://localhost:8050/api/v1/data)", + ) + parser.add_argument( + "--token", + default=None, + help="Token Bearer API (format : decpinfo_xxxxx) — omis si l'API n'exige pas d'auth", + ) + parser.add_argument( + "--runs", + type=int, + default=5, + help="Nombre de répétitions par scénario (défaut : 5)", + ) + args = parser.parse_args() + + if args.runs < 1: + print("--runs doit être ≥ 1", file=sys.stderr) + sys.exit(1) + + run_benchmark(args.url, args.token, args.runs) + + +if __name__ == "__main__": + main() From 6c591aeb08d4b5a646fb708728ec119746599566 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:15:19 +0200 Subject: [PATCH 15/34] API: endpoint /data avec pagination et filtres (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.py | 95 +++++++++++++++++++++++++++++++- tests/api/test_endpoints_data.py | 47 ++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/api/test_endpoints_data.py diff --git a/src/api/routes.py b/src/api/routes.py index a4728b3..0d5d4a8 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,6 +1,9 @@ -from flask_smorest import Blueprint +from flask import request +from flask_smorest import Blueprint, abort from src.api.auth import require_token +from src.api.filters import FilterError, build_where +from src.db import count_marches, query_marches from src.db import schema as duckdb_schema bp = Blueprint( @@ -10,6 +13,54 @@ bp = Blueprint( description="API privée decp.info — accès tabulaire aux marchés publics.", ) +MAX_PAGE_SIZE = 1000 + + +def _parse_pagination(): + try: + page = int(request.args.get("page", "1")) + page_size = int(request.args.get("page_size", "50")) + except ValueError: + abort(400, message="page et page_size doivent être des entiers") + if page < 1: + abort(400, message="page doit être >= 1") + if page_size < 1 or page_size > MAX_PAGE_SIZE: + abort( + 400, + message=f"page_size doit être dans [1, {MAX_PAGE_SIZE}]", + ) + return page, page_size + + +def _parse_columns(): + raw = request.args.get("columns") + if not raw: + return None + cols = [c.strip() for c in raw.split(",") if c.strip()] + unknown = [c for c in cols if c not in duckdb_schema] + if unknown: + abort(400, message=f"Colonnes inconnues : {unknown}") + return cols + + +def _build_links(page, page_size, total): + base = request.path + qs = request.args.to_dict(flat=False) + qs.pop("page", None) + + def url_for(p): + from urllib.parse import urlencode + + params = [(k, v) for k, vs in qs.items() for v in vs] + params.append(("page", str(p))) + return f"{base}?{urlencode(params)}" + + prev_url = url_for(page - 1) if page > 1 else None + next_url = None + if total is None or page * page_size < total: + next_url = url_for(page + 1) + return {"prev": prev_url, "next": next_url} + @bp.route("/health") def health(): @@ -23,3 +74,45 @@ def schema(): """Liste des colonnes disponibles dans le dataset DECP.""" cols = [{"name": name, "type": str(dtype)} for name, dtype in duckdb_schema.items()] return {"columns": cols} + + +@bp.route("/data") +@require_token +def data(): + """Endpoint tabulaire : filtres dynamiques sur les colonnes DECP.""" + import polars as pl + import polars.selectors as cs + + page, page_size = _parse_pagination() + columns = _parse_columns() + count = request.args.get("count", "true").lower() != "false" + + try: + where_sql, params, order_sql = build_where( + list(request.args.items(multi=True)), duckdb_schema + ) + except FilterError as e: + abort(400, message=str(e), errors={"field": e.field}) + + df = query_marches( + where_sql=where_sql, + params=params, + columns=columns, + order_by=order_sql, + limit=page_size, + offset=(page - 1) * page_size, + ) + + # JSON ne sérialise pas date/datetime nativement → cast en string ISO + df_ready = df.with_columns(cs.temporal().cast(pl.String)) + + total = count_marches(where_sql, params) if count else None + meta = {"page": page, "page_size": page_size} + if total is not None: + meta["total"] = total + + return { + "data": df_ready.to_dicts(), + "meta": meta, + "links": _build_links(page, page_size, total), + } diff --git a/tests/api/test_endpoints_data.py b/tests/api/test_endpoints_data.py new file mode 100644 index 0000000..b45e0c7 --- /dev/null +++ b/tests/api/test_endpoints_data.py @@ -0,0 +1,47 @@ +def test_data_without_token_returns_401(api_client): + client, _ = api_client + resp = client.get("/api/v1/data") + assert resp.status_code == 401 + + +def test_data_default_pagination(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert set(body.keys()) >= {"data", "meta", "links"} + assert isinstance(body["data"], list) + assert len(body["data"]) <= 50 # default page_size + assert body["meta"]["page"] == 1 + assert body["meta"]["page_size"] == 50 + assert "total" in body["meta"] + + +def test_data_count_false_omits_total(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?count=false", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert "total" not in body["meta"] + + +def test_data_page_size_max_enforced(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?page_size=5000", headers=valid_token_header) + assert resp.status_code == 400 + + +def test_data_page_size_below_min_rejected(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?page_size=0", headers=valid_token_header) + assert resp.status_code == 400 + + +def test_data_pagination_links(api_client, valid_token_header): + client, _ = api_client + resp = client.get("/api/v1/data?page=1&page_size=1", headers=valid_token_header) + body = resp.get_json() + assert body["links"]["prev"] is None + if body["meta"]["total"] > 1: + assert body["links"]["next"] is not None + assert "page=2" in body["links"]["next"] From 6d2a95d775d63c6b7ebf07a1903bc226a7f1b027 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:16:27 +0200 Subject: [PATCH 16/34] =?UTF-8?q?API:=20retirer=20benchmark=20non=20demand?= =?UTF-8?q?=C3=A9=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/benchmark_api.py | 208 ----------------------------------------- 1 file changed, 208 deletions(-) delete mode 100644 tests/benchmark_api.py diff --git a/tests/benchmark_api.py b/tests/benchmark_api.py deleted file mode 100644 index 9431342..0000000 --- a/tests/benchmark_api.py +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env python -"""Benchmark de l'endpoint /data de l'API privée decp.info. - -Usage : - python tests/benchmark_api.py --url http://localhost:8050/api/v1/data --token decpinfo_xxx - python tests/benchmark_api.py --url https://decp.info/api/v1/data --token decpinfo_xxx --runs 20 - -Le script mesure le temps de réponse de chaque scénario et affiche un tableau -de statistiques (min / médiane / p95 / max). -""" - -import argparse -import statistics -import sys -import time - -import httpx - -SCENARIOS: list[dict] = [ - { - "name": "sans filtre (page 1, 50 résultats)", - "params": {"page": 1, "page_size": 50}, - }, - { - "name": "filtre __exact sur département", - "params": {"acheteur_departement_code__exact": "44", "page_size": 50}, - }, - { - "name": "filtre __contains sur objet", - "params": {"objet__contains": "informatique", "page_size": 50}, - }, - { - "name": "filtre __greater sur date", - "params": {"dateNotification__greater": "2024-01-01", "page_size": 50}, - }, - { - "name": "filtre __strictly_greater sur montant", - "params": {"montant__strictly_greater": "100000", "page_size": 50}, - }, - { - "name": "filtre __in (CPV multiples)", - "params": {"cpv_8__in": "72000000,72200000", "page_size": 50}, - }, - { - "name": "filtre __isnull", - "params": {"montant__isnull": "", "page_size": 50}, - }, - { - "name": "tri desc + colonnes sélectionnées", - "params": { - "dateNotification__sort": "desc", - "columns": "uid,objet,montant,dateNotification", - "page_size": 50, - }, - }, - { - "name": "filtres combinés", - "params": { - "acheteur_departement_code__exact": "75", - "dateNotification__greater": "2023-01-01", - "montant__strictly_greater": "50000", - "dateNotification__sort": "desc", - "page_size": 50, - }, - }, - { - "name": "count=false (économise COUNT(*))", - "params": {"page_size": 50, "count": "false"}, - }, - { - "name": "page 2", - "params": {"page": 2, "page_size": 50}, - }, -] - -COL_NAME = 44 -COL_STATUS = 8 -COL_STAT = 10 - - -def percentile(data: list[float], p: float) -> float: - if not data: - return float("nan") - sorted_data = sorted(data) - k = (len(sorted_data) - 1) * p / 100 - lo, hi = int(k), min(int(k) + 1, len(sorted_data) - 1) - return sorted_data[lo] + (sorted_data[hi] - sorted_data[lo]) * (k - lo) - - -def run_benchmark(base_url: str, token: str | None, runs: int) -> None: - auth_header = {"Authorization": f"Bearer {token}"} if token else {} - results: list[dict] = [] - - print(f"\nBenchmark {base_url}") - print(f"Scénarios : {len(SCENARIOS)} | Répétitions : {runs}\n") - - for scenario in SCENARIOS: - timings: list[float] = [] - last_status = 0 - - for _ in range(runs): - headers = auth_header - url = base_url - params = scenario["params"] - - try: - t0 = time.perf_counter() - resp = httpx.get(url, params=params, headers=headers, timeout=30) - elapsed = (time.perf_counter() - t0) * 1000 - last_status = resp.status_code - timings.append(elapsed) - except httpx.RequestError as exc: - print(f" ERREUR réseau : {exc}") - last_status = 0 - break - - if timings: - results.append( - { - "name": scenario["name"], - "status": last_status, - "min": min(timings), - "median": percentile(timings, 50), - "p95": percentile(timings, 95), - "max": max(timings), - "mean": statistics.mean(timings), - "runs": len(timings), - } - ) - status_str = f"[{last_status}]" - print( - f" {scenario['name'][:COL_NAME]:<{COL_NAME}}" - f" {status_str:<{COL_STATUS}}" - f" médiane {results[-1]['median']:>7.1f} ms" - f" p95 {results[-1]['p95']:>7.1f} ms" - ) - else: - print(f" {scenario['name'][:COL_NAME]:<{COL_NAME}} ÉCHEC") - - _print_summary(results) - - -def _print_summary(results: list[dict]) -> None: - if not results: - return - - sep = "-" * (COL_NAME + COL_STATUS + 4 * (COL_STAT + 3) + 6) - header = ( - f"\n{'Scénario':<{COL_NAME}} {'Status':<{COL_STATUS}}" - f" {'Min (ms)':>{COL_STAT}}" - f" {'Médiane (ms)':>{COL_STAT}}" - f" {'P95 (ms)':>{COL_STAT}}" - f" {'Max (ms)':>{COL_STAT}}" - ) - print(f"\n{'=' * len(sep)}") - print("RÉSUMÉ") - print(f"{'=' * len(sep)}") - print(header) - print(sep) - - for r in results: - status_str = f"[{r['status']}]" - print( - f"{r['name'][:COL_NAME]:<{COL_NAME}}" - f" {status_str:<{COL_STATUS}}" - f" {r['min']:>{COL_STAT}.1f}" - f" {r['median']:>{COL_STAT}.1f}" - f" {r['p95']:>{COL_STAT}.1f}" - f" {r['max']:>{COL_STAT}.1f}" - ) - - print(sep) - medians = [r["median"] for r in results] - print( - f"\nMédiane globale : {statistics.mean(medians):.1f} ms" - f" | Pire p95 : {max(r['p95'] for r in results):.1f} ms" - ) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Benchmark de l'API privée decp.info") - parser.add_argument( - "--url", - default="http://localhost:8050/api/v1/data", - help="URL complète de l'endpoint /data (défaut : http://localhost:8050/api/v1/data)", - ) - parser.add_argument( - "--token", - default=None, - help="Token Bearer API (format : decpinfo_xxxxx) — omis si l'API n'exige pas d'auth", - ) - parser.add_argument( - "--runs", - type=int, - default=5, - help="Nombre de répétitions par scénario (défaut : 5)", - ) - args = parser.parse_args() - - if args.runs < 1: - print("--runs doit être ≥ 1", file=sys.stderr) - sys.exit(1) - - run_benchmark(args.url, args.token, args.runs) - - -if __name__ == "__main__": - main() From 04c8bb8b6dd1863006a610e1dfbc10d71180e63b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:28:18 +0200 Subject: [PATCH 17/34] =?UTF-8?q?API:=20tests=20d'int=C3=A9gration=20filtr?= =?UTF-8?q?es/tri/columns=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/api/test_endpoints_data.py | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/api/test_endpoints_data.py b/tests/api/test_endpoints_data.py index b45e0c7..8911062 100644 --- a/tests/api/test_endpoints_data.py +++ b/tests/api/test_endpoints_data.py @@ -45,3 +45,60 @@ def test_data_pagination_links(api_client, valid_token_header): if body["meta"]["total"] > 1: assert body["links"]["next"] is not None assert "page=2" in body["links"]["next"] + + +def test_data_filter_exact_string(api_client, valid_token_header): + client, _ = api_client + # On choisit une valeur qui existe dans test.parquet : récupère via la 1re ligne + base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json() + assert base["data"], "test.parquet vide ?" + uid = base["data"][0]["uid"] + + resp = client.get(f"/api/v1/data?uid__exact={uid}", headers=valid_token_header) + assert resp.status_code == 200 + body = resp.get_json() + assert all(row["uid"] == uid for row in body["data"]) + + +def test_data_unknown_column_filter_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?colonne_inexistante__exact=x", + headers=valid_token_header, + ) + assert resp.status_code == 400 + + +def test_data_columns_selection(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?columns=uid,objet&page_size=3", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + for row in body["data"]: + assert set(row.keys()) == {"uid", "objet"} + + +def test_data_columns_unknown_returns_400(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?columns=uid,foobar", + headers=valid_token_header, + ) + assert resp.status_code == 400 + + +def test_data_sort_desc(api_client, valid_token_header): + client, _ = api_client + resp = client.get( + "/api/v1/data?dateNotification__sort=desc&page_size=5", + headers=valid_token_header, + ) + assert resp.status_code == 200 + body = resp.get_json() + dates = [ + row["dateNotification"] for row in body["data"] if row.get("dateNotification") + ] + assert dates == sorted(dates, reverse=True) From 912507b1d9b74c28c4f71e636063a55edee2a51c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:31:22 +0200 Subject: [PATCH 18/34] API: compteur de consommation SQLite asynchrone (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/__init__.py | 6 ++++ src/api/routes.py | 11 ++++++- src/api/tracking.py | 67 ++++++++++++++++++++++++++++++++++++++ tests/api/conftest.py | 5 +-- tests/api/test_tracking.py | 36 ++++++++++++++++++++ 5 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 src/api/tracking.py create mode 100644 tests/api/test_tracking.py diff --git a/src/api/__init__.py b/src/api/__init__.py index 4dd2e5b..e68f48b 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -18,3 +18,9 @@ def init_api(server) -> None: api = Api(server) api.register_blueprint(routes.bp) + + import os + + from src.api import tracking + + tracking.start_worker(os.environ["USERS_DB_PATH"]) diff --git a/src/api/routes.py b/src/api/routes.py index 0d5d4a8..cce2167 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,6 +1,7 @@ -from flask import request +from flask import g, request from flask_smorest import Blueprint, abort +from src.api import tracking from src.api.auth import require_token from src.api.filters import FilterError, build_where from src.db import count_marches, query_marches @@ -62,6 +63,14 @@ def _build_links(page, page_size, total): return {"prev": prev_url, "next": next_url} +@bp.after_request +def _track_consumption(response): + token_id = getattr(g, "token_id", None) + if token_id is not None: + tracking.enqueue_counter_update(token_id) + return response + + @bp.route("/health") def health(): """Sonde de santé, sans authentification.""" diff --git a/src/api/tracking.py b/src/api/tracking.py new file mode 100644 index 0000000..00d25c5 --- /dev/null +++ b/src/api/tracking.py @@ -0,0 +1,67 @@ +import queue +import threading +from typing import Optional + +from src.api import tokens_db +from src.utils import logger + +_STOP_SENTINEL = object() +_queue: Optional[queue.Queue] = None +_worker_thread: Optional[threading.Thread] = None +_db_path: Optional[str] = None + + +def _worker_loop(q: queue.Queue, db_path: str) -> None: + while True: + item = q.get() + try: + if item is _STOP_SENTINEL: + return + kind, payload = item + if kind == "counter": + token_id = payload + try: + tokens_db.increment_usage(db_path, token_id) + except Exception: # noqa: BLE001 + logger.warning( + "tracking: échec increment_usage token_id=%s", + token_id, + exc_info=True, + ) + finally: + q.task_done() + + +def start_worker(db_path: str) -> None: + global _queue, _worker_thread, _db_path + if _worker_thread is not None and _worker_thread.is_alive(): + return + _db_path = db_path + _queue = queue.Queue() + _worker_thread = threading.Thread( + target=_worker_loop, args=(_queue, db_path), daemon=True + ) + _worker_thread.start() + + +def stop_worker() -> None: + global _worker_thread, _queue + if _worker_thread is None: + return + _queue.put(_STOP_SENTINEL) + _worker_thread.join(timeout=2.0) + _worker_thread = None + _queue = None + + +def enqueue_counter_update(token_id: int) -> None: + if _queue is None: + return # tracking désactivé (tests par ex.) + _queue.put(("counter", token_id)) + + +def flush(timeout: float = 2.0) -> None: + """Attend que la queue soit drainée. Utile en test.""" + if _queue is None: + return + _queue.join() diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 67dc109..5a83507 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -19,12 +19,13 @@ def api_client(monkeypatch, tmp_path): monkeypatch.setenv("USERS_DB_PATH", str(db_path)) from flask import Flask - from src.api import init_api, tokens_db + from src.api import init_api, tokens_db, tracking tokens_db.init_schema(db_path) server = Flask(__name__) init_api(server) - return server.test_client(), db_path + yield server.test_client(), db_path + tracking.stop_worker() @pytest.fixture diff --git a/tests/api/test_tracking.py b/tests/api/test_tracking.py new file mode 100644 index 0000000..dd0a662 --- /dev/null +++ b/tests/api/test_tracking.py @@ -0,0 +1,36 @@ +from src.api import tokens_db, tracking + + +def test_counter_worker_increments_count(temp_db): + _, token_id = tokens_db.create_token(temp_db, "x") + + tracking.start_worker(str(temp_db)) + try: + tracking.enqueue_counter_update(token_id) + tracking.enqueue_counter_update(token_id) + # Laisser le worker drainer la queue + tracking.flush(timeout=2.0) + finally: + tracking.stop_worker() + + rows = tokens_db.list_tokens(temp_db) + assert rows[0]["count_total"] == 2 + assert rows[0]["last_used_at"] is not None + + +def test_after_request_hook_increments_counter_async(api_client, valid_token_header): + client, db_path = api_client + # Récupérer le token_id du token créé par la fixture + from src.api import tokens_db, tracking + + rows = tokens_db.list_tokens(db_path) + assert len(rows) == 1 # vérification du token créé par la fixture + + # Faire une requête (qui doit déclencher l'incrément) + client.get("/api/v1/health") # pas authentifiée → ne compte pas + client.get("/api/v1/schema", headers=valid_token_header) + + tracking.flush(timeout=2.0) + + rows = tokens_db.list_tokens(db_path) + assert rows[0]["count_total"] == 1 From 985af0fdc771d094f7ccd0664bc6a0419b9061d2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:34:52 +0200 Subject: [PATCH 19/34] API: envoi Matomo fire-and-forget en async (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/routes.py | 7 +++++ src/api/tracking.py | 59 +++++++++++++++++++++++++++++++++++--- tests/api/test_tracking.py | 43 +++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/api/routes.py b/src/api/routes.py index cce2167..afda3ce 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -68,6 +68,13 @@ def _track_consumption(response): token_id = getattr(g, "token_id", None) if token_id is not None: tracking.enqueue_counter_update(token_id) + tracking.enqueue_matomo_event( + token_id=token_id, + path=request.path, + query_string=request.query_string.decode("utf-8", errors="replace"), + status_code=response.status_code, + user_agent=request.headers.get("User-Agent", ""), + ) return response diff --git a/src/api/tracking.py b/src/api/tracking.py index 00d25c5..c952d6b 100644 --- a/src/api/tracking.py +++ b/src/api/tracking.py @@ -1,14 +1,16 @@ +import os import queue import threading from typing import Optional +import httpx + from src.api import tokens_db from src.utils import logger _STOP_SENTINEL = object() _queue: Optional[queue.Queue] = None _worker_thread: Optional[threading.Thread] = None -_db_path: Optional[str] = None def _worker_loop(q: queue.Queue, db_path: str) -> None: @@ -28,15 +30,55 @@ def _worker_loop(q: queue.Queue, db_path: str) -> None: token_id, exc_info=True, ) + elif kind == "matomo": + try: + _post_matomo(**payload) + except Exception: # noqa: BLE001 + logger.warning("tracking: échec envoi Matomo", exc_info=True) finally: q.task_done() +def _post_matomo(url: str, params: dict) -> None: + """POST fire-and-forget vers la Tracking API Matomo. Mockable en test.""" + httpx.post(url, data=params, timeout=5.0) + + +def enqueue_matomo_event( + token_id: int, + path: str, + query_string: str, + status_code: int, + user_agent: str, +) -> None: + if _queue is None: + return + if os.getenv("MATOMO_TRACKING_ENABLED", "false").lower() != "true": + return + url = os.getenv("MATOMO_URL") + site_id = os.getenv("MATOMO_SITE_ID") + if not url or not site_id: + return + full_url = f"https://decp.info{path}" + if query_string: + full_url += f"?{query_string}" + params = { + "idsite": site_id, + "rec": "1", + "url": full_url, + "action_name": f"API {path}", + "uid": f"token-{token_id}", + "dimension1": str(token_id), + "dimension2": str(status_code), + "ua": user_agent, + } + _queue.put(("matomo", {"url": url, "params": params})) + + def start_worker(db_path: str) -> None: - global _queue, _worker_thread, _db_path + global _queue, _worker_thread if _worker_thread is not None and _worker_thread.is_alive(): return - _db_path = db_path _queue = queue.Queue() _worker_thread = threading.Thread( target=_worker_loop, args=(_queue, db_path), daemon=True @@ -64,4 +106,13 @@ def flush(timeout: float = 2.0) -> None: """Attend que la queue soit drainée. Utile en test.""" if _queue is None: return - _queue.join() + # Use a sentinel approach to properly honour the timeout + done_event = threading.Event() + + def _wait(): + _queue.join() + done_event.set() + + t = threading.Thread(target=_wait, daemon=True) + t.start() + done_event.wait(timeout=timeout) diff --git a/tests/api/test_tracking.py b/tests/api/test_tracking.py index dd0a662..44d9f54 100644 --- a/tests/api/test_tracking.py +++ b/tests/api/test_tracking.py @@ -34,3 +34,46 @@ def test_after_request_hook_increments_counter_async(api_client, valid_token_hea rows = tokens_db.list_tokens(db_path) assert rows[0]["count_total"] == 1 + + +def test_matomo_disabled_skips_call(monkeypatch, api_client, valid_token_header): + monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "false") + client, _ = api_client + + from src.api import tracking + + called = [] + monkeypatch.setattr( + tracking, + "_post_matomo", + lambda **kw: called.append(kw), + ) + client.get("/api/v1/health") + tracking.flush(timeout=2.0) + assert called == [] + + +def test_matomo_enabled_posts_event(monkeypatch, api_client, valid_token_header): + monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "true") + monkeypatch.setenv("MATOMO_URL", "https://matomo.example/matomo.php") + monkeypatch.setenv("MATOMO_SITE_ID", "42") + + from src.api import tracking + + captured = [] + monkeypatch.setattr( + tracking, + "_post_matomo", + lambda **kw: captured.append(kw), + ) + + client, _ = api_client + client.get("/api/v1/schema", headers=valid_token_header) + tracking.flush(timeout=2.0) + + assert len(captured) == 1 + call = captured[0] + assert call["params"]["idsite"] == "42" + assert call["params"]["rec"] == "1" + assert "token-" in call["params"]["uid"] + assert call["params"]["dimension2"] == "200" From c683034e2bf018b42745a9f9c11d5204b0db3e45 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:37:21 +0200 Subject: [PATCH 20/34] API: documentation OpenAPI et CHANGELOG (#78) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 16 ++++++++++++++++ src/api/routes.py | 11 ++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5dbcd..a416ce7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## [Unreleased] + +### Ajouté + +- API privée tabulaire `/api/v1/data` (filtres dynamiques, pagination, tri). +- Endpoint `/api/v1/schema` (description du dataset). +- Endpoint `/api/v1/health` (sonde monitoring). +- Documentation interactive Swagger UI à `/api/v1/swagger`. +- CLI d'administration des tokens : `python -m src.api.tokens_cli` (create, list, revoke). +- Suivi de consommation : compteurs SQLite (`api_tokens.count_total`, `last_used_at`) + événements Matomo async. + +### Configuration + +- Nouvelles variables d'environnement : `USERS_DB_PATH`, `MATOMO_URL`, `MATOMO_SITE_ID`, `MATOMO_TRACKING_ENABLED`. +- Création de 2 Custom Dimensions côté Matomo : `dimension1=token_id`, `dimension2=http_status`. + ##### 2.7.7 (11 mai 2026) - Suppression des mentions sur les profils d'acheteur. Omnikles/Safetender publie via l'API DUME et Klekoon ne publie pas, mais c'est peut-être pas le seul, donc je préfère supprimer et refaire un tour. diff --git a/src/api/routes.py b/src/api/routes.py index afda3ce..9ffa0aa 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -95,7 +95,16 @@ def schema(): @bp.route("/data") @require_token def data(): - """Endpoint tabulaire : filtres dynamiques sur les colonnes DECP.""" + """Récupère des marchés publics filtrés. + + Filtres en query string sous la forme `__=`. + + Opérateurs : exact, contains, notcontains, less, greater, + strictly_less, strictly_greater, in, notin, isnull, isnotnull, sort. + + Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000), + columns (csv), count (true|false ; mettre false pour économiser le COUNT(*)). + """ import polars as pl import polars.selectors as cs From 2f0cbada34ca0bdfa8ebea62151625bdd4a20e9c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:45:26 +0200 Subject: [PATCH 21/34] API: corriger flush() et isolation test compteur (#78) --- src/api/tracking.py | 14 +++----------- tests/api/test_tracking.py | 1 + 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/api/tracking.py b/src/api/tracking.py index c952d6b..0762e1b 100644 --- a/src/api/tracking.py +++ b/src/api/tracking.py @@ -104,15 +104,7 @@ def enqueue_counter_update(token_id: int) -> None: def flush(timeout: float = 2.0) -> None: """Attend que la queue soit drainée. Utile en test.""" - if _queue is None: + q = _queue + if q is None: return - # Use a sentinel approach to properly honour the timeout - done_event = threading.Event() - - def _wait(): - _queue.join() - done_event.set() - - t = threading.Thread(target=_wait, daemon=True) - t.start() - done_event.wait(timeout=timeout) + q.join() diff --git a/tests/api/test_tracking.py b/tests/api/test_tracking.py index 44d9f54..36e57a6 100644 --- a/tests/api/test_tracking.py +++ b/tests/api/test_tracking.py @@ -4,6 +4,7 @@ from src.api import tokens_db, tracking def test_counter_worker_increments_count(temp_db): _, token_id = tokens_db.create_token(temp_db, "x") + tracking.stop_worker() # reset any worker left by earlier tests tracking.start_worker(str(temp_db)) try: tracking.enqueue_counter_update(token_id) From 63f51d7b98a0658062c222c81c793aaadd2f2561 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:47:03 +0200 Subject: [PATCH 22/34] =?UTF-8?q?API:=20mention=20sur=20la=20page=20=C3=80?= =?UTF-8?q?=20propos=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/pages/a-propos.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/pages/a-propos.py b/src/pages/a-propos.py index 26d2ba7..2e69d49 100644 --- a/src/pages/a-propos.py +++ b/src/pages/a-propos.py @@ -49,6 +49,16 @@ Vous pouvez consommer les données qui alimentent decp.info - en les téléchargeant [sur data.gouv.fr](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire) (Parquet, CSV), pensez à lire la description du jeu de données - en interrogeant l'[API REST ouverte](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire#user-content-api-rest) +""" + ), + html.H4("API privée", id="api-privee"), + dcc.Markdown( + """ +Une API HTTP est disponible pour accéder aux mêmes données par programme. +Documentation interactive : [Swagger UI](/api/v1/swagger). + +L'accès se fait sur token. Pour en obtenir un, contactez +[colin@maudry.com](mailto:colin@maudry.com). """ ), html.H4("Contact", id="contact"), @@ -149,6 +159,11 @@ J'enregistre également les données suivantes, de manière anonyme, afin de mie href="#donnees-brutes", className="toc-link", ), + html.A( + "API privée", + href="#api-privee", + className="toc-link", + ), html.A( "Contact", href="#contact", className="toc-link" ), From 8dc528791864ebc11ed8ca3c36b9d884cb0fa943 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 18 May 2026 11:47:05 +0200 Subject: [PATCH 23/34] =?UTF-8?q?Ajouts=20=C3=A0=20flask=20#78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .template.env | 9 +++-- src/api/auth.py | 29 ++++++++------- uv.lock | 99 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 18 deletions(-) diff --git a/.template.env b/.template.env index aef7230..7cdf125 100644 --- a/.template.env +++ b/.template.env @@ -15,10 +15,10 @@ DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom # Formulaire de contact SENDER_SERVER_DOMAIN="mail.example.com" # serveur SMTP -LOGIN_PASSWORD="" # mot de passe du serveur -LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP -FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From) -TO_EMAIL="to@example.com" # adresse de destination des emails (To) +LOGIN_PASSWORD="" # mot de passe du serveur +LOGIN_EMAIL="connect@example.fr" # adresse utilisée pour se connecter au serveur SMTP +FROM_EMAIL="from@example.com" # adresse d'envoi des emails (From) +TO_EMAIL="to@example.com" # adresse de destination des emails (To) # Matomo MATOMO_ID_SITE= @@ -26,6 +26,7 @@ MATOMO_BASE_URL= MATOMO_TOKEN= # API privée +DISABLE_API_AUTH="false" USERS_DB_PATH=./users.sqlite MATOMO_URL=https://analytics.maudry.com/matomo.php MATOMO_SITE_ID=14 diff --git a/src/api/auth.py b/src/api/auth.py index 1985684..915d35c 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -5,6 +5,8 @@ from flask import abort, g, jsonify, make_response, request from src.api import tokens_db +API_AUTH_DISABLED = os.getenv("API_AUTH_DISABLED", "False").lower() == "true" + def _abort_401(message: str): resp = make_response(jsonify({"message": message}), 401) @@ -14,19 +16,20 @@ def _abort_401(message: str): def require_token(fn): @wraps(fn) def wrapper(*args, **kwargs): - header = request.headers.get("Authorization", "") - if not header.startswith("Bearer "): - _abort_401("missing_token") - token = header[len("Bearer ") :].strip() - if not token: - _abort_401("missing_token") - db_path = os.environ["USERS_DB_PATH"] - row = tokens_db.get_token_by_plaintext(db_path, token) - if row is None: - _abort_401("invalid_token") - if row["revoked_at"] is not None: - _abort_401("revoked_token") - g.token_id = row["id"] + if not API_AUTH_DISABLED: + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + _abort_401("missing_token") + token = header[len("Bearer ") :].strip() + if not token: + _abort_401("missing_token") + db_path = os.environ["USERS_DB_PATH"] + row = tokens_db.get_token_by_plaintext(db_path, token) + if row is None: + _abort_401("invalid_token") + if row["revoked_at"] is not None: + _abort_401("revoked_token") + g.token_id = row["id"] return fn(*args, **kwargs) return wrapper diff --git a/uv.lock b/uv.lock index 9ac4ecc..0b698dc 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] +[[package]] +name = "apispec" +version = "6.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/f1/1f5a9332df3ecd90cc5ab69bc58a4174b8ba2ac1720c4c26b01d20751bf5/apispec-6.10.0.tar.gz", hash = "sha256:0a888555cd4aa5fb7176041be15684154fd8961055e1672e703abf737e8761bf", size = 80631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/88/e149b20246c4689e7d27163e4e3bb8946ef31617cfb3b9c427813483fe5b/apispec-6.10.0-py3-none-any.whl", hash = "sha256:8ff23e0de9a0ceb62ff70047241126315bd17b8d0565a567934c0156f4ddbb43", size = 31313 }, +] + +[package.optional-dependencies] +marshmallow = [ + { name = "marshmallow" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -46,6 +63,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, ] +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561 }, + { url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448 }, + { url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093 }, + { url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836 }, + { url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798 }, + { url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891 }, + { url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955 }, + { url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323 }, + { url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626 }, + { url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588 }, + { url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162 }, + { url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482 }, + { url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362 }, + { url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162 }, + { url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118 }, + { url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329 }, + { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630 }, + { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707 }, + { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280 }, + { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094 }, + { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605 }, + { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353 }, + { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298 }, + { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375 }, + { url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980 }, + { url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449 }, +] + [[package]] name = "backports-zstd" version = "1.3.0" @@ -760,7 +811,7 @@ wheels = [ [[package]] name = "decp-info" -version = "2.7.6" +version = "2.7.7" source = { virtual = "." } dependencies = [ { name = "dash", extra = ["compress"] }, @@ -772,8 +823,10 @@ dependencies = [ { name = "duckdb" }, { name = "flask-caching" }, { name = "flask-cors" }, + { name = "flask-smorest" }, { name = "gunicorn" }, { name = "httpx" }, + { name = "marshmallow" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pandas", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "plotly", extra = ["express"] }, @@ -805,8 +858,10 @@ requires-dist = [ { name = "duckdb" }, { name = "flask-caching" }, { name = "flask-cors", specifier = ">=6.0.2" }, + { name = "flask-smorest", specifier = ">=0.46.0" }, { name = "gunicorn" }, { name = "httpx" }, + { name = "marshmallow", specifier = ">=3.20.0" }, { name = "pandas" }, { name = "plotly", extras = ["express"] }, { name = "polars" }, @@ -993,6 +1048,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257 }, ] +[[package]] +name = "flask-smorest" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apispec", extra = ["marshmallow"] }, + { name = "flask" }, + { name = "marshmallow" }, + { name = "webargs" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/b0/5edd5f3231ea26b52e786588c45d786df26929d2be8fec634bcb871b2a3f/flask_smorest-0.47.0.tar.gz", hash = "sha256:a415ce7db3d7e63dd854eb16d59047686dbf5d31c4913e245fa5657f88e7788d", size = 77600 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/19/53cc4e4d4a3d61362053acd359a425f3e9f2c792698dd4dbb5f5441b5f97/flask_smorest-0.47.0-py3-none-any.whl", hash = "sha256:9329d8697f8710ea6446e9b7876a4f58af2dbc02116d10ebac6f24a7c0ebdea9", size = 32379 }, +] + [[package]] name = "geobuf" version = "2.0.1" @@ -1332,6 +1403,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, ] +[[package]] +name = "marshmallow" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/7e/1dbd4096eb7c148cd2841841916f78820bb85a4d80a0c25c02d30815a7fb/marshmallow-4.3.0.tar.gz", hash = "sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880", size = 224485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/e0/ff24e25218bb59eb6290a530cea40651b14068b6e3659b20f9c175179632/marshmallow-4.3.0-py3-none-any.whl", hash = "sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46", size = 49148 }, +] + [[package]] name = "more-itertools" version = "10.8.0" @@ -2483,6 +2567,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232 }, ] +[[package]] +name = "webargs" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/64/17afc4e6f47eef154a553c6e56adcc9f1ac3003305c7df978d11aa62937e/webargs-8.7.1.tar.gz", hash = "sha256:799bf9039c76c23fd8dc1951107a75a9e561203c15d6ae8f89c1e46e234636c1", size = 97351 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/ef/b0d17f3943429358184449771b592e0e1d33bbeaa6ed326434a95eac187b/webargs-8.7.1-py3-none-any.whl", hash = "sha256:a184aed9d2509e6e14ab99ee3e9dc3a614c7070affe94cd4dfdb0d002e0a6e5f", size = 32500 }, +] + [[package]] name = "webdriver-manager" version = "4.0.2" From 8a853b23849d9e1df72b8cc096af4b26587be52c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:01:41 +0200 Subject: [PATCH 24/34] =?UTF-8?q?docs:=20spec=20bootstrap=20r=C3=A9silient?= =?UTF-8?q?=20donn=C3=A9es=20et=20sch=C3=A9ma=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...otstrap-resilient-donnees-schema-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md diff --git a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md new file mode 100644 index 0000000..71da219 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md @@ -0,0 +1,201 @@ +# Bootstrap résilient des données et du schéma + +**Date :** 2026-06-12 +**Branche :** `feature/78_api` +**Statut :** design approuvé, à implémenter + +## Problème + +L'API et l'appli Web Dash partagent le même process Python (`gunicorn app:server`). +L'API sera consommée par des clients en production. Or decp.info tombe « de temps +en temps », et comme tout est dans le même process, une chute du Web emporte l'API. + +**Diagnostic (clé).** Les chutes ne sont **pas** des crashs runtime aléatoires +pendant l'ingestion. Ce sont des **échecs de bootstrap au déploiement** : + +- env oubliée lors d'un déploiement (ex. `DATA_FILE_PARQUET_PATH` vide) ; +- `DATA_FILE_PARQUET_PATH` (désormais une URL data.gouv.fr) injoignable ou + pointant vers un parquet absent/invalide à cause d'un souci dans + `decp-processing` ; +- `DATA_SCHEMA_PATH` (URL data.gouv.fr) qui renvoie une erreur. + +Le process démarre sur des ressources manquantes/invalides, lève une exception +**au moment de l'import** (`src/db.py` et `src/utils/data.py` font leur bootstrap +au niveau module), et meurt au boot — API comprise. + +## Pourquoi pas « séparer les process » ? + +La séparation API / Web protège contre la **contagion runtime** (un callback Dash +qui tue le worker). Elle ne protège **pas** contre le mode d'échec réel : si les +deux process partagent les mêmes ressources de bootstrap (parquet, schéma, env), +ils échouent **tous les deux** au démarrage, de manière identique. + +Le levier réel est donc le **durcissement du bootstrap avec fallback +« last-known-good »** : garantir présence + validité des ressources, et sinon +repartir sur les dernières ressources fonctionnelles. + +La séparation des process reste **hors périmètre** de ce spec. La couche données +(`src/db.py`) est déjà process-agnostique et sans dépendance à Dash, donc la +séparation restera bon marché à dégainer plus tard _si_ un vrai crash runtime +touche l'API. On ne paie pas cette complexité tant qu'on n'en a pas la preuve. + +## État actuel du code (post-merge `main`) + +### `src/db.py` + +- Bootstrap au niveau module : `DB_PATH = _ensure_database()` puis ouverture d'une + connexion DuckDB read-only partagée et lecture du `schema`. +- `build_database()` écrit dans un fichier temporaire puis `os.replace()` atomique : + un build qui échoue en cours de route **laisse l'ancien DuckDB intact**. ✅ +- **Faille :** `should_rebuild()` appelle `get_last_modified(parquet_path)` qui + fait un `httpx.head(...).headers["last-modified"]` **sans aucune gestion + d'erreur** (`src/utils/__init__.py:12`). URL injoignable, lente, ou sans en-tête + `last-modified` ⇒ exception ⇒ remonte jusqu'à l'import ⇒ **mort au démarrage + alors qu'un DuckDB valide existe sur disque**. +- **Faille :** `_load_source_frame()` fait `assert os.path.exists(parquet_path)` + (non-http) et `scan_parquet` (http) — les deux peuvent lever et ne sont pas + rattrapés au niveau de `_ensure_database()`. + +### `src/utils/data.py` — `get_data_schema()` + +- Tente l'URL, attrape **seulement 4 erreurs httpx** (`ReadTimeout`, `ReadError`, + `ConnectError`, `ConnectTimeout`), sinon fallback sur `DATA_SCHEMA_LOCAL`. +- **Faille :** pas de `raise_for_status()`. Quand data.gouv renvoie une **erreur + HTTP** (le cas cité par l'utilisateur), `.json()` ne contient pas `"fields"` ⇒ + `KeyError` ligne 92, **sans fallback local**. +- **Faille :** un payload distant valide JSON mais malformé (sans `"fields"`) + plante aussi sans fallback. +- **Faille :** si les deux sources échouent, `original_schema["fields"]` ⇒ + `KeyError` opaque au lieu d'une erreur claire. + +## Décisions + +1. **Schéma** : URL primaire, fallback local. (confirmé) +2. **DuckDB** : réutiliser le dernier DuckDB construit en cas d'échec. (confirmé) +3. **Last-known-good réel du schéma** : après un fetch distant réussi, persister + le schéma localement pour que le fallback soit toujours le _dernier schéma + distant fonctionnel_. (confirmé) + +### Nuance sur le chemin de persistance du schéma + +L'utilisateur a demandé « écrire dans `DATA_SCHEMA_LOCAL` ». Mais en dev, +`DATA_SCHEMA_LOCAL = ../decp-processing/dist/schema.json` — un fichier d'un **autre +repo**. L'écraser au boot salirait ce repo. + +**Choix retenu (à confirmer en relecture) :** introduire un **chemin de cache +dédié que l'app possède**, distinct du fichier statique de secours. + +- `DATA_SCHEMA_PATH` (URL) — source primaire. +- `DATA_SCHEMA_CACHE` (nouveau, ex. défaut `./schema.cache.json`) — écrit après + chaque fetch distant réussi ; lu en fallback n°1 (dernier distant fonctionnel). +- `DATA_SCHEMA_LOCAL` — graine statique de secours (fichier `decp-processing`), + **lue mais jamais écrite**. + +Chaîne de résolution : `URL → cache → local statique → RuntimeError`. + +## Design + +### Invariant 1 — Bootstrap DuckDB (`src/db.py`) + +> Le process démarre tant qu'un DuckDB exploitable existe, quel que soit l'état de +> la source distante/parquet. Échec dur **seulement** s'il n'existe aucune base +> (cold start). + +Garde-fou unique dans `_ensure_database()` : + +```python +def _ensure_database() -> Path: + db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb")) + parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "") + lock_path = db_path.with_suffix(".duckdb.lock") + db_exists = db_path.exists() + with open(lock_path, "w") as lock_fd: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + try: + if should_rebuild(db_path, parquet_path): + build_database(db_path) + except Exception as e: + if db_exists: + logger.error( + f"Bootstrap données KO ({e}). " + f"Réutilisation du DuckDB existant : {db_path}" + ) + else: + logger.critical("Aucune base DuckDB et reconstruction impossible.") + raise + return db_path +``` + +- `should_rebuild()` qui lève (via `get_last_modified()`) est désormais rattrapé : + base existante ⇒ on la réutilise. +- `build_database()` qui lève sur parquet invalide : base existante intacte + (atomicité) ⇒ on la réutilise. +- Le mode `DEVELOPMENT` sort de `should_rebuild()` **avant** tout appel réseau + (court-circuit `if dev and not force: return False`) ⇒ dev inchangé. + +### Invariant 2 — Schéma (`src/utils/data.py`) + +> Un schéma valide non-vide est toujours retourné si une source (distant, cache, +> ou local statique) en fournit un. Échec dur seulement si aucune. + +```python +def get_data_schema() -> dict: + raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH")) # dict valide | None + if raw is not None: + _persist_schema_cache(raw, os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")) + else: + raw = _load_schema_file(os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")) + if raw is None: + raw = _load_schema_file(os.getenv("DATA_SCHEMA_LOCAL", "")) + if raw is None: + raise RuntimeError("Aucun schéma disponible (distant, cache ni local).") + return OrderedDict((c["name"], c) for c in raw["fields"]) +``` + +Helpers : + +- `_fetch_remote_schema(url) -> dict | None` : `get(...).raise_for_status().json()`, + **valide `"fields" in data`**, attrape large (`httpx.HTTPError`, + `json.JSONDecodeError`, `KeyError`), log l'erreur, renvoie `None` sur tout échec. +- `_load_schema_file(path) -> dict | None` : lit le fichier s'il existe, parse, + valide `"fields"`, renvoie `None` sinon. +- `_persist_schema_cache(data, path)` : écriture atomique (tmp + `os.replace`) ; + un échec d'écriture est loggé mais **non bloquant** (le schéma en mémoire reste + valide). + +## Tests (TDD) + +Couvrir chaque branche de fallback. Sans dépendre du réseau réel. + +**Schéma (`get_data_schema` / helpers) :** + +1. URL OK ⇒ schéma distant retourné **et** cache écrit. +2. URL renvoie une erreur HTTP (mock 500) ⇒ fallback cache. +3. URL renvoie un JSON malformé (sans `"fields"`) ⇒ fallback cache. +4. URL KO + cache présent ⇒ schéma du cache. +5. URL KO + cache absent + local statique présent ⇒ schéma local. +6. Toutes sources KO ⇒ `RuntimeError` claire. +7. Échec d'écriture du cache ⇒ schéma quand même retourné (non bloquant). + +**Bootstrap DuckDB (`_ensure_database`) :** 8. `should_rebuild` lève + DuckDB existant ⇒ réutilisé, pas d'exception. 9. `build_database` lève + DuckDB existant ⇒ réutilisé, pas d'exception. 10. Échec + **aucun** DuckDB (cold start) ⇒ ré-lève. 11. Cas nominal : rebuild nécessaire et possible ⇒ build effectué. + +Mocker `get_last_modified` / `build_database` / `httpx.get` ; utiliser des fichiers +DuckDB et schéma temporaires (`tmp_path`). + +## Hors périmètre + +- Séparation des process API / Web (reportée — voir plus haut). +- Surveillance / alerting externe (les logs `error`/`critical` suffisent pour ce lot). +- Validation fine du contenu du parquet au-delà de « lisible par Polars/DuckDB ». + +## Variables d'environnement + +| Variable | Rôle | Changement | +| ------------------------ | --------------------------------------- | -------------------- | +| `DATA_FILE_PARQUET_PATH` | Source parquet (URL ou chemin) | inchangé | +| `DATA_SCHEMA_PATH` | URL schéma (primaire) | inchangé | +| `DATA_SCHEMA_LOCAL` | Fichier schéma de secours statique | **lu, jamais écrit** | +| `DATA_SCHEMA_CACHE` | Cache last-known-good du schéma distant | **nouveau** | +| `DUCKDB_PATH` | Fichier DuckDB | inchangé | + +Mettre à jour `.template.env` avec `DATA_SCHEMA_CACHE`. From 0ebbfcd872398b5d118a9022f02fc19f80f39e43 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:10:13 +0200 Subject: [PATCH 25/34] =?UTF-8?q?docs:=20spec=20sch=C3=A9ma=20en=20cache?= =?UTF-8?q?=20seul=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...otstrap-resilient-donnees-schema-design.md | 88 ++++++++++++------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md index 71da219..6b0f74e 100644 --- a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md +++ b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md @@ -70,28 +70,43 @@ touche l'API. On ne paie pas cette complexité tant qu'on n'en a pas la preuve. ## Décisions -1. **Schéma** : URL primaire, fallback local. (confirmé) +1. **Schéma** : URL primaire, **cache seul** en fallback (on supprime + `DATA_SCHEMA_LOCAL`). (confirmé) 2. **DuckDB** : réutiliser le dernier DuckDB construit en cas d'échec. (confirmé) 3. **Last-known-good réel du schéma** : après un fetch distant réussi, persister - le schéma localement pour que le fallback soit toujours le _dernier schéma - distant fonctionnel_. (confirmé) + le schéma dans un cache local pour que le fallback soit toujours le _dernier + schéma distant fonctionnel_. (confirmé) -### Nuance sur le chemin de persistance du schéma +### Chemin de persistance du schéma : `DATA_SCHEMA_CACHE` seul -L'utilisateur a demandé « écrire dans `DATA_SCHEMA_LOCAL` ». Mais en dev, -`DATA_SCHEMA_LOCAL = ../decp-processing/dist/schema.json` — un fichier d'un **autre -repo**. L'écraser au boot salirait ce repo. - -**Choix retenu (à confirmer en relecture) :** introduire un **chemin de cache -dédié que l'app possède**, distinct du fichier statique de secours. +On remplace `DATA_SCHEMA_LOCAL` (qui pointait, en dev, vers +`../decp-processing/dist/schema.json` — un fichier cross-repo qu'on ne veut pas +écraser) par un **cache unique possédé par l'app**. - `DATA_SCHEMA_PATH` (URL) — source primaire. - `DATA_SCHEMA_CACHE` (nouveau, ex. défaut `./schema.cache.json`) — écrit après - chaque fetch distant réussi ; lu en fallback n°1 (dernier distant fonctionnel). -- `DATA_SCHEMA_LOCAL` — graine statique de secours (fichier `decp-processing`), - **lue mais jamais écrite**. + chaque fetch distant réussi, lu en fallback. -Chaîne de résolution : `URL → cache → local statique → RuntimeError`. +Chaîne de résolution : `URL → cache → RuntimeError`. + +**Pourquoi c'est suffisant.** Le déploiement est en place sur un VM persistant +(`ssh → cd /var/www/APP_NAME → git pull → restart systemd`), donc le fichier de +cache survit aux déploiements — **même garantie de persistance que le DuckDB +réutilisé**. Tous les incidents constatés (env oubliée, parquet KO, URL schéma en +erreur) surviennent sur un **redéploiement** d'un hôte déjà chaud, où le cache a +déjà été écrit par un boot précédent réussi ⇒ couvert. + +**Seul cas non couvert (assumé) :** le _cold start absolu_ — un hôte qui n'a jamais +booté avec succès **et** URL distante down au même instant. Étroit, non-récurrent. +Fermable plus tard par une graine commitée in-repo si jamais il se matérialise +(YAGNI). + +**Contraintes :** + +- `DATA_SCHEMA_CACHE` (`./schema.cache.json`) doit être **`.gitignore`** — sinon le + `git pull` du déploiement entrerait en conflit. (Comme `decp.duckdb` aujourd'hui.) +- En dev, plus de fallback vers le schéma frais de `decp-processing` : on bascule + sur le cache (dernier schéma data.gouv). Acceptable, l'URL restant primaire. ## Design @@ -135,20 +150,19 @@ def _ensure_database() -> Path: ### Invariant 2 — Schéma (`src/utils/data.py`) -> Un schéma valide non-vide est toujours retourné si une source (distant, cache, -> ou local statique) en fournit un. Échec dur seulement si aucune. +> Un schéma valide non-vide est toujours retourné si une source (distant ou cache) +> en fournit un. Échec dur seulement si aucune. ```python def get_data_schema() -> dict: + cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json") raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH")) # dict valide | None if raw is not None: - _persist_schema_cache(raw, os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")) + _persist_schema_cache(raw, cache_path) else: - raw = _load_schema_file(os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")) + raw = _load_schema_file(cache_path) if raw is None: - raw = _load_schema_file(os.getenv("DATA_SCHEMA_LOCAL", "")) - if raw is None: - raise RuntimeError("Aucun schéma disponible (distant, cache ni local).") + raise RuntimeError("Aucun schéma disponible (ni distant ni cache).") return OrderedDict((c["name"], c) for c in raw["fields"]) ``` @@ -173,11 +187,15 @@ Couvrir chaque branche de fallback. Sans dépendre du réseau réel. 2. URL renvoie une erreur HTTP (mock 500) ⇒ fallback cache. 3. URL renvoie un JSON malformé (sans `"fields"`) ⇒ fallback cache. 4. URL KO + cache présent ⇒ schéma du cache. -5. URL KO + cache absent + local statique présent ⇒ schéma local. -6. Toutes sources KO ⇒ `RuntimeError` claire. -7. Échec d'écriture du cache ⇒ schéma quand même retourné (non bloquant). +5. URL KO + cache absent ⇒ `RuntimeError` claire. +6. Échec d'écriture du cache ⇒ schéma quand même retourné (non bloquant). -**Bootstrap DuckDB (`_ensure_database`) :** 8. `should_rebuild` lève + DuckDB existant ⇒ réutilisé, pas d'exception. 9. `build_database` lève + DuckDB existant ⇒ réutilisé, pas d'exception. 10. Échec + **aucun** DuckDB (cold start) ⇒ ré-lève. 11. Cas nominal : rebuild nécessaire et possible ⇒ build effectué. +**Bootstrap DuckDB (`_ensure_database`) :** + +7. `should_rebuild` lève + DuckDB existant ⇒ réutilisé, pas d'exception. +8. `build_database` lève + DuckDB existant ⇒ réutilisé, pas d'exception. +9. Échec + **aucun** DuckDB (cold start) ⇒ ré-lève. +10. Cas nominal : rebuild nécessaire et possible ⇒ build effectué. Mocker `get_last_modified` / `build_database` / `httpx.get` ; utiliser des fichiers DuckDB et schéma temporaires (`tmp_path`). @@ -190,12 +208,16 @@ DuckDB et schéma temporaires (`tmp_path`). ## Variables d'environnement -| Variable | Rôle | Changement | -| ------------------------ | --------------------------------------- | -------------------- | -| `DATA_FILE_PARQUET_PATH` | Source parquet (URL ou chemin) | inchangé | -| `DATA_SCHEMA_PATH` | URL schéma (primaire) | inchangé | -| `DATA_SCHEMA_LOCAL` | Fichier schéma de secours statique | **lu, jamais écrit** | -| `DATA_SCHEMA_CACHE` | Cache last-known-good du schéma distant | **nouveau** | -| `DUCKDB_PATH` | Fichier DuckDB | inchangé | +| Variable | Rôle | Changement | +| ------------------------ | --------------------------------------- | ------------ | +| `DATA_FILE_PARQUET_PATH` | Source parquet (URL ou chemin) | inchangé | +| `DATA_SCHEMA_PATH` | URL schéma (primaire) | inchangé | +| `DATA_SCHEMA_LOCAL` | Ancien fichier de secours statique | **supprimé** | +| `DATA_SCHEMA_CACHE` | Cache last-known-good du schéma distant | **nouveau** | +| `DUCKDB_PATH` | Fichier DuckDB | inchangé | -Mettre à jour `.template.env` avec `DATA_SCHEMA_CACHE`. +À faire côté config : + +- Ajouter `DATA_SCHEMA_CACHE` à `.template.env`, retirer `DATA_SCHEMA_LOCAL` de + `.template.env` / `.env`. +- Ajouter `schema.cache.json` (ou la valeur de `DATA_SCHEMA_CACHE`) au `.gitignore`. From 20f81b573220d36479894ca8551c01d9e05a2539 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:23:35 +0200 Subject: [PATCH 26/34] docs: spec surfaces C/D (chargements pages au boot) (#78) Co-Authored-By: Claude Opus 4.8 --- ...otstrap-resilient-donnees-schema-design.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md index 6b0f74e..4472d09 100644 --- a/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md +++ b/docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md @@ -177,6 +177,62 @@ Helpers : un échec d'écriture est loggé mais **non bloquant** (le schéma en mémoire reste valide). +### Invariant 3 — Chargements au niveau module des pages + +> L'import d'une page (exécuté au boot via `use_pages`) ne doit jamais tuer le +> démarrage à cause d'une ressource externe KO. Une ressource indisponible +> dégrade gracieusement l'affichage. + +Audit des chargements à l'import (tous les `layout` de pages sont au niveau +module ⇒ leur contenu s'exécute au boot). Deux points de rupture **externes** : + +**C — `src/pages/tableau.py:36-38`.** `get_last_modified(URL parquet)` fait un +HTTP HEAD **sans gestion d'erreur** (URL injoignable, en-tête `last-modified` +absent) ⇒ import KO ⇒ boot KO. C'est le même piège que `db.py`, mais dans une page. + +Correctif : un helper best-effort dans `src/utils/__init__.py` qui ne lève jamais +et retombe sur le mtime du DuckDB (garanti présent par l'Invariant 1) : + +```python +def get_data_update_timestamp(parquet_path: str, fallback_path: str | None = None) -> float | None: + """Date de MAJ des données, best-effort, sans jamais lever (usage au boot).""" + try: + return get_last_modified(parquet_path) + except Exception as e: + logger.warning(f"Date de mise à jour des données indisponible ({e})") + if fallback_path: + try: + return os.path.getmtime(fallback_path) + except OSError: + pass + return None +``` + +`tableau.py` l'utilise et gère le cas `None` (affiche « date inconnue », +`update_date_iso = ""`). + +**D — `src/pages/a-propos.py:103`.** `get_sources_tables(SOURCE_STATS_CSV_PATH)` +(`src/figures.py:121`) fait `pl.read_csv(source_path)` mais ne rattrape que +`URLError, HTTPError` — pas les erreurs Polars, ni `source_path` vide/`None`, ni +fichier absent ⇒ import KO ⇒ boot KO. + +Correctif : élargir le `except` et gérer le chemin vide : + +```python +def get_sources_tables(source_path) -> html.Div: + try: + if not source_path: + raise ValueError("SOURCE_STATS_CSV_PATH non défini") + dff = pl.read_csv(source_path) + except Exception as e: + logger.warning(f"Sources de données indisponibles ({e})") + return html.Div("Sources de données momentanément indisponibles.") + ... # suite inchangée +``` + +Hors périmètre des pages : `data/departements.json` + `.geojson` (fichiers +in-repo apportés par `git pull`, pas pilotés par env/URL — voir Hors périmètre). + ## Tests (TDD) Couvrir chaque branche de fallback. Sans dépendre du réseau réel. @@ -200,11 +256,24 @@ Couvrir chaque branche de fallback. Sans dépendre du réseau réel. Mocker `get_last_modified` / `build_database` / `httpx.get` ; utiliser des fichiers DuckDB et schéma temporaires (`tmp_path`). +**Chargements de pages (Invariant 3) :** + +11. `get_data_update_timestamp` : `get_last_modified` lève + `fallback_path` + existant ⇒ retourne le mtime du fallback (pas d'exception). +12. `get_data_update_timestamp` : tout KO (lève + pas de fallback) ⇒ `None`. +13. `get_data_update_timestamp` : cas nominal ⇒ retourne la valeur de + `get_last_modified` (mocké). +14. `get_sources_tables(None)` ⇒ `html.Div` de repli (pas d'exception). +15. `get_sources_tables("/inexistant.csv")` ⇒ `html.Div` de repli. +16. `get_sources_tables()` ⇒ `html.Div` contenant la `DataTable`. + ## Hors périmètre - Séparation des process API / Web (reportée — voir plus haut). - Surveillance / alerting externe (les logs `error`/`critical` suffisent pour ce lot). - Validation fine du contenu du parquet au-delà de « lisible par Polars/DuckDB ». +- Durcissement des `open()` in-repo (`data/departements.json` + `.geojson`) : + fichiers versionnés, apportés par `git pull`, jamais pilotés par env/URL (YAGNI). ## Variables d'environnement @@ -215,6 +284,7 @@ DuckDB et schéma temporaires (`tmp_path`). | `DATA_SCHEMA_LOCAL` | Ancien fichier de secours statique | **supprimé** | | `DATA_SCHEMA_CACHE` | Cache last-known-good du schéma distant | **nouveau** | | `DUCKDB_PATH` | Fichier DuckDB | inchangé | +| `SOURCE_STATS_CSV_PATH` | CSV stats sources (page À propos, D) | inchangé | À faire côté config : From afd6590c5448d77710481914251eac7227bb09e6 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:30:36 +0200 Subject: [PATCH 27/34] =?UTF-8?q?docs:=20plan=20d'impl=C3=A9mentation=20bo?= =?UTF-8?q?otstrap=20r=C3=A9silient=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...6-12-bootstrap-resilient-donnees-schema.md | 665 ++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-bootstrap-resilient-donnees-schema.md diff --git a/docs/superpowers/plans/2026-06-12-bootstrap-resilient-donnees-schema.md b/docs/superpowers/plans/2026-06-12-bootstrap-resilient-donnees-schema.md new file mode 100644 index 0000000..c674eca --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-bootstrap-resilient-donnees-schema.md @@ -0,0 +1,665 @@ +# Bootstrap résilient des données et du schéma — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rendre le démarrage de decp.info résilient aux ressources externes KO (parquet, schéma, stats), pour que l'API partagée ne tombe plus à cause d'un déploiement défaillant. + +**Architecture:** Trois invariants. (1) Le DuckDB est réutilisé si la reconstruction échoue. (2) Le schéma suit la chaîne `URL → cache → RuntimeError`, le dernier schéma distant fonctionnel étant persisté localement. (3) Les chargements de pages au boot ne lèvent jamais sur une ressource externe KO. Une tâche préalable répare le baseline de tests laissé rouge par le merge de `main`. + +**Tech Stack:** Python, Polars, DuckDB, httpx, flask-caching, pytest (+ monkeypatch). + +**Spec de référence:** `docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md` + +--- + +## Structure des fichiers + +| Fichier | Responsabilité | Action | +| ------------------------------------- | ---------------------------------------- | ----------------------------- | +| `tests/test_db.py` | Tests bootstrap DuckDB | Modifier (réparer + ajouter) | +| `tests/conftest.py` | Setup déterministe des tests | Modifier (seed schéma) | +| `tests/schema.fixture.json` | Schéma complet figé pour tests (offline) | Créer (commité) | +| `tests/test_schema.py` | Tests résolution schéma | Créer | +| `tests/test_page_loads.py` | Tests chargements best-effort (C/D) | Créer | +| `src/utils/data.py` | Résolution schéma | Modifier | +| `src/db.py` | Bootstrap DuckDB | Modifier (`_ensure_database`) | +| `src/utils/__init__.py` | Helper date MAJ best-effort | Modifier (ajout fonction) | +| `src/pages/tableau.py` | Date MAJ au niveau module | Modifier | +| `src/figures.py` | `get_sources_tables` | Modifier | +| `.template.env`, `.env`, `.gitignore` | Config | Modifier | + +--- + +## Task 1 : Réparer le baseline de tests `test_db.py` + +Le merge de `main` a changé `build_database(db_path)` (1 arg, parquet lu via env) et memoïsé `get_last_modified` (besoin du contexte d'app). Trois corrections pour repartir au vert. **Aucune logique applicative ne change ici.** + +**Files:** + +- Modify: `tests/test_db.py` + +- [ ] **Step 1 : Corriger la fixture `built_db` (signature `build_database`)** + +Dans `tests/test_db.py`, remplacer : + +```python + from src.db import build_database + + build_database(db_path, parquet_path) + return db_path +``` + +par : + +```python + from src.db import build_database + + build_database(db_path) + return db_path +``` + +(L'env `DATA_FILE_PARQUET_PATH` est déjà posé juste au-dessus dans la fixture.) + +- [ ] **Step 2 : Corriger `test_concurrent_build_serialized`** + +Ajouter `monkeypatch` à la signature et poser l'env du parquet ; corriger l'appel `build_database`. + +Remplacer la ligne de signature : + +```python +def test_concurrent_build_serialized(tmp_path): +``` + +par : + +```python +def test_concurrent_build_serialized(tmp_path, monkeypatch): +``` + +Juste après `df.write_parquet(parquet_path)`, ajouter : + +```python + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path)) +``` + +Et dans `worker()`, remplacer : + +```python + if db.should_rebuild(db_path, parquet_path): + db.build_database(db_path, parquet_path) +``` + +par : + +```python + if db.should_rebuild(db_path, parquet_path): + db.build_database(db_path) +``` + +- [ ] **Step 3 : Corriger les 2 tests prod de `should_rebuild` (memoïsation)** + +`should_rebuild` non-dev appelle `get_last_modified`, memoïsé et inutilisable hors contexte d'app en test. On le monkeypatche pour tester la logique de comparaison de dates. + +Dans `test_should_rebuild_prod_when_parquet_newer`, juste avant l'`assert`, ajouter : + +```python + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) +``` + +Dans `test_should_not_rebuild_prod_when_parquet_older`, juste avant l'`assert`, ajouter la même ligne : + +```python + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) +``` + +- [ ] **Step 4 : Lancer les tests, vérifier le vert** + +Run: `rtk proxy python -m pytest tests/test_db.py -q` +Expected: tous PASS (plus aucun `TypeError`/`AttributeError`). + +- [ ] **Step 5 : Commit** + +```bash +git add tests/test_db.py +git commit -m "test: réparer le baseline test_db cassé par le merge (#78)" +``` + +--- + +## Task 2 : Schéma résilient — chaîne `URL → cache → RuntimeError` + +Réécrire `get_data_schema` avec helpers robustes et persistance du dernier schéma distant fonctionnel. Supprimer `DATA_SCHEMA_LOCAL` au profit de `DATA_SCHEMA_CACHE`. + +**Files:** + +- Create: `tests/schema.fixture.json`, `tests/test_schema.py` +- Modify: `tests/conftest.py`, `src/utils/data.py`, `.template.env`, `.env`, `.gitignore` + +- [ ] **Step 1 : Créer le fixture schéma complet (offline, déterministe)** + +Run: + +```bash +cp ../decp-processing/dist/schema.json tests/schema.fixture.json +test -s tests/schema.fixture.json && python -c "import json;assert 'fields' in json.load(open('tests/schema.fixture.json'))" && echo OK +``` + +Expected: `OK` (le fixture contient bien une clé `fields`). + +- [ ] **Step 2 : Rendre la résolution du schéma déterministe en test (conftest)** + +Dans `tests/conftest.py`, ajouter `import json` en tête (avec les autres imports) puis, au niveau module **avant** toute logique existante (juste après la ligne `_DB_PATH = Path(...)`), ajouter : + +```python +# 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) +``` + +- [ ] **Step 3 : Écrire les tests schéma (échouent d'abord)** + +Créer `tests/test_schema.py` : + +```python +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() +``` + +- [ ] **Step 4 : Lancer les tests, vérifier l'échec** + +Run: `rtk proxy python -m pytest tests/test_schema.py -q` +Expected: FAIL (les helpers/comportements n'existent pas encore ; `get_data_schema` actuel plante différemment). + +- [ ] **Step 5 : Réécrire `get_data_schema` + helpers** + +Dans `src/utils/data.py`, remplacer entièrement la fonction `get_data_schema` (actuellement lignes ~68-95) par : + +```python +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: + 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"]) +``` + +Vérifier que les imports en tête de `src/utils/data.py` couvrent : `json`, `os`, `OrderedDict`, `httpx`, `get` (déjà présents : `from httpx import HTTPError, get`). `HTTPError` peut devenir inutilisé — voir Step 7. + +- [ ] **Step 6 : Lancer les tests, vérifier le vert** + +Run: `rtk proxy python -m pytest tests/test_schema.py -q` +Expected: 6 PASS. + +- [ ] **Step 7 : Nettoyer imports + config + gitignore** + +Dans `src/utils/data.py`, si `HTTPError` n'est plus utilisé, remplacer `from httpx import HTTPError, get` par `from httpx import get` (garder `import httpx`). Vérifier avec : + +Run: `rtk proxy python -m ruff check src/utils/data.py` +Expected: pas d'erreur F401. + +Dans `.gitignore`, ajouter sous la ligne `**/decp.duckdb` : + +``` +**/schema.cache.json +``` + +Dans `.template.env`, supprimer la ligne `DATA_SCHEMA_PATH_LOCAL=...` et ajouter : + +``` +DATA_SCHEMA_CACHE=./schema.cache.json +``` + +Dans `.env` (local, non versionné), supprimer la ligne `DATA_SCHEMA_LOCAL=...` et ajouter `DATA_SCHEMA_CACHE=./schema.cache.json`. + +- [ ] **Step 8 : Commit** + +```bash +git add tests/schema.fixture.json tests/test_schema.py tests/conftest.py src/utils/data.py .template.env .gitignore +git commit -m "feat: schéma résilient URL→cache + suppression DATA_SCHEMA_LOCAL (#78)" +``` + +--- + +## Task 3 : Bootstrap DuckDB résilient + +Ajouter le garde-fou `try/except` dans `_ensure_database` : réutiliser le DuckDB existant si la reconstruction échoue ; ne lever qu'en cold start. + +**Files:** + +- Modify: `src/db.py:117-128` (`_ensure_database`) +- Test: `tests/test_db.py` + +- [ ] **Step 1 : Écrire les tests (échouent d'abord)** + +Ajouter à la fin de `tests/test_db.py` : + +```python +def _raise(*args, **kwargs): + raise RuntimeError("boom") + + +def test_ensure_database_reuses_db_when_should_rebuild_raises(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"existing") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", _raise) + result = db._ensure_database() # ne doit pas lever + assert result == dbf + assert dbf.read_bytes() == b"existing" + + +def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"existing") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", _raise) + db._ensure_database() # ne doit pas lever + assert dbf.read_bytes() == b"existing" + + +def test_ensure_database_raises_on_cold_start(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" # n'existe pas + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", _raise) + with pytest.raises(RuntimeError): + db._ensure_database() + + +def test_ensure_database_builds_when_needed(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"old") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://x") + called = {} + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", lambda p: called.setdefault("built", p)) + db._ensure_database() + assert called.get("built") == dbf +``` + +Ajouter `import pytest` en tête de `tests/test_db.py` s'il n'y est pas déjà (il y est). + +- [ ] **Step 2 : Lancer, vérifier l'échec** + +Run: `rtk proxy python -m pytest tests/test_db.py -q -k ensure_database` +Expected: FAIL (le `try/except` n'existe pas ; les exceptions remontent). + +- [ ] **Step 3 : Implémenter le garde-fou** + +Dans `src/db.py`, remplacer `_ensure_database` (lignes ~117-128) par : + +```python +def _ensure_database() -> Path: + db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb")) + parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "") + lock_path = db_path.with_suffix(".duckdb.lock") + db_exists = db_path.exists() + + with open(lock_path, "w") as lock_fd: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + try: + if should_rebuild(db_path, parquet_path): + build_database(db_path) + else: + logger.debug("Base de données déjà disponible et à jour.") + except Exception as e: + if db_exists: + logger.error( + f"Bootstrap données KO ({e}). " + f"Réutilisation du DuckDB existant : {db_path}" + ) + else: + logger.critical("Aucune base DuckDB et reconstruction impossible.") + raise + return db_path +``` + +- [ ] **Step 4 : Lancer, vérifier le vert** + +Run: `rtk proxy python -m pytest tests/test_db.py -q` +Expected: tous PASS (anciens + 4 nouveaux). + +- [ ] **Step 5 : Commit** + +```bash +git add src/db.py tests/test_db.py +git commit -m "feat: réutiliser le DuckDB existant si le bootstrap échoue (#78)" +``` + +--- + +## Task 4 : Chargements de pages best-effort (C + D) + +`tableau.py` (date MAJ via `get_last_modified`) et `a-propos.py` (stats via `get_sources_tables`) chargent au boot et peuvent tuer le démarrage. On les rend best-effort. + +**Files:** + +- Create: `tests/test_page_loads.py` +- Modify: `src/utils/__init__.py`, `src/pages/tableau.py`, `src/figures.py` + +- [ ] **Step 1 : Écrire les tests (échouent d'abord)** + +Créer `tests/test_page_loads.py` : + +```python +import os + + +def test_update_timestamp_falls_back_to_db_mtime(tmp_path, monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + def boom(*a, **k): + raise RuntimeError("net down") + + monkeypatch.setattr(u, "get_last_modified", boom) + fb = tmp_path / "decp.duckdb" + fb.write_bytes(b"x") + assert get_data_update_timestamp("http://x", str(fb)) == os.path.getmtime(str(fb)) + + +def test_update_timestamp_none_when_all_fail(monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + def boom(*a, **k): + raise RuntimeError("net down") + + monkeypatch.setattr(u, "get_last_modified", boom) + assert get_data_update_timestamp("http://x", None) is None + + +def test_update_timestamp_nominal(monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + monkeypatch.setattr(u, "get_last_modified", lambda p: 123.0) + assert get_data_update_timestamp("http://x", None) == 123.0 + + +def test_sources_tables_none_path(): + from src.figures import get_sources_tables + + div = get_sources_tables(None) + assert "indisponible" in str(div.children).lower() + + +def test_sources_tables_missing_file(): + from src.figures import get_sources_tables + + div = get_sources_tables("/does/not/exist.csv") + assert "indisponible" in str(div.children).lower() + + +def test_sources_tables_valid_csv(tmp_path): + from dash import dash_table + + from src.figures import get_sources_tables + + csv = tmp_path / "s.csv" + csv.write_text( + "nom,organisation,nb_marchés,nb_acheteurs,code,url,unique\n" + "Source A,Org A,5,2,XA,http://a,1\n" + ) + div = get_sources_tables(str(csv)) + assert isinstance(div.children, dash_table.DataTable) +``` + +- [ ] **Step 2 : Lancer, vérifier l'échec** + +Run: `rtk proxy python -m pytest tests/test_page_loads.py -q` +Expected: FAIL (`get_data_update_timestamp` n'existe pas ; `get_sources_tables(None)` plante). + +- [ ] **Step 3 : Ajouter `get_data_update_timestamp` dans `src/utils/__init__.py`** + +À la fin de `src/utils/__init__.py`, ajouter : + +```python +def get_data_update_timestamp( + parquet_path: str, fallback_path: str | None = None +) -> float | None: + """Date de MAJ des données, best-effort, sans jamais lever (usage au boot).""" + try: + return get_last_modified(parquet_path) + except Exception as e: + logger.warning(f"Date de mise à jour des données indisponible ({e})") + if fallback_path: + try: + return os.path.getmtime(fallback_path) + except OSError: + pass + return None +``` + +(`os` et `logger` sont déjà disponibles dans ce module.) + +- [ ] **Step 4 : Utiliser le helper dans `tableau.py`** + +Dans `src/pages/tableau.py`, remplacer l'import ligne 24 : + +```python +from src.utils import get_last_modified, logger +``` + +par : + +```python +from src.utils import get_data_update_timestamp, logger +``` + +Et remplacer les lignes 36-38 : + +```python +update_date_timestamp = get_last_modified(os.getenv("DATA_FILE_PARQUET_PATH", "")) +update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y") +update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat() +``` + +par : + +```python +update_date_timestamp = get_data_update_timestamp( + os.getenv("DATA_FILE_PARQUET_PATH", ""), + os.getenv("DUCKDB_PATH", "./decp.duckdb"), +) +if update_date_timestamp is not None: + update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y") + update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat() +else: + update_date = "date inconnue" + update_date_iso = "" +``` + +- [ ] **Step 5 : Élargir `get_sources_tables` dans `src/figures.py`** + +Dans `src/figures.py` (`get_sources_tables`, ~lignes 122-125), remplacer : + +```python + try: + dff = pl.read_csv(source_path) + except (URLError, HTTPError): + return html.Div("Erreur de connexion") +``` + +par : + +```python + try: + if not source_path: + raise ValueError("SOURCE_STATS_CSV_PATH non défini") + dff = pl.read_csv(source_path) + except Exception as e: + logger.warning(f"Sources de données indisponibles ({e})") + return html.Div("Sources de données momentanément indisponibles.") +``` + +Si `URLError`/`HTTPError` (import ligne 3 `from urllib.error import HTTPError, URLError`) ne sont plus utilisés ailleurs dans le fichier, supprimer cet import. + +Run: `rtk proxy python -m ruff check src/figures.py` +Expected: pas d'erreur F401. + +- [ ] **Step 6 : Lancer, vérifier le vert** + +Run: `rtk proxy python -m pytest tests/test_page_loads.py -q` +Expected: 6 PASS. + +- [ ] **Step 7 : Smoke test — l'import des modules modifiés ne casse pas** + +Run: `rtk proxy python -c "import src.figures, src.pages.tableau; print('import OK')"` +Expected: `import OK`. +(NB : `a-propos.py` a un tiret, non importable par nom — son correctif `get_sources_tables` est couvert par les tests unitaires du Step 6 et la page sera validée par la suite Selenium au Step 8.) + +- [ ] **Step 8 : Suite complète** + +Run: `rtk proxy python -m pytest -q` +Expected: vert (hors tests Selenium nécessitant Chrome, à lancer si l'environnement le permet). + +- [ ] **Step 9 : Commit** + +```bash +git add tests/test_page_loads.py src/utils/__init__.py src/pages/tableau.py src/figures.py +git commit -m "feat: chargements de pages best-effort au boot (tableau, sources) (#78)" +``` + +--- + +## Notes d'exécution + +- **Dev hors-ligne 1er run :** « cache seul » supprime le fallback in-repo. Au tout premier démarrage sur une machine sans `schema.cache.json` ni réseau, le boot lèvera `RuntimeError`. En conditions normales (URL OK une fois, ou cache déjà présent) c'est transparent. Les tests sont rendus déterministes via `tests/schema.fixture.json` (Task 2). +- **CHANGELOG :** penser à ajouter une entrée (résilience bootstrap données/schéma) avant de finaliser la PR #78, si le projet le tient à jour. +- **`get_last_modified` reste memoïsé** : non modifié ici ; le cache FileSystem est vidé à chaque boot (`rmtree` dans `app.py`), donc pas de last-modified périmé entre déploiements. From 21c65c34fb7571acfdb9261ea88c4a92c9c08c33 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 11:37:54 +0200 Subject: [PATCH 28/34] =?UTF-8?q?test:=20r=C3=A9parer=20le=20baseline=20te?= =?UTF-8?q?st=5Fdb=20cass=C3=A9=20par=20le=20merge=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tests/test_db.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/test_db.py b/tests/test_db.py index bba7de5..10e9725 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -31,6 +31,7 @@ def test_should_rebuild_prod_when_parquet_newer(parquet_and_db, monkeypatch): os.utime(db, (now, now)) os.utime(parquet, (now + 10, now + 10)) monkeypatch.setenv("DEVELOPMENT", "false") + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) assert should_rebuild(db, parquet) is True @@ -42,6 +43,7 @@ def test_should_not_rebuild_prod_when_parquet_older(parquet_and_db, monkeypatch) os.utime(parquet, (now, now)) os.utime(db, (now + 10, now + 10)) monkeypatch.setenv("DEVELOPMENT", "false") + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) assert should_rebuild(db, parquet) is False @@ -66,6 +68,7 @@ def test_should_rebuild_dev_when_rebuild_forced(parquet_and_db, monkeypatch): os.utime(parquet, (now + 10, now + 10)) monkeypatch.setenv("DEVELOPMENT", "true") monkeypatch.setenv("REBUILD_DUCKDB", "true") + monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime) assert should_rebuild(db, parquet) is True @@ -145,7 +148,7 @@ def built_db(tmp_path, monkeypatch): from src.db import build_database - build_database(db_path, parquet_path) + build_database(db_path) return db_path @@ -190,8 +193,15 @@ def test_build_creates_derived_tables(built_db): def test_query_marches_returns_polars_frame(built_db, monkeypatch): - monkeypatch.setenv( - "DATA_FILE_PARQUET_PATH", str(built_db.parent / "source.parquet") + parquet_path = built_db.parent / "source.parquet" + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path)) + # Patch on both the source module and dst namespace: the reload re-imports + # get_last_modified from src.utils, so src.utils must be patched to survive. + monkeypatch.setattr( + "src.utils.get_last_modified", lambda p: parquet_path.stat().st_mtime + ) + monkeypatch.setattr( + "src.db.get_last_modified", lambda p: parquet_path.stat().st_mtime ) # Force src.db to load pointing at this test DB. import importlib @@ -239,7 +249,7 @@ def test_query_marches_with_offset(): assert set(page_0["uid"].to_list()).isdisjoint(set(page_1["uid"].to_list())) -def test_concurrent_build_serialized(tmp_path): +def test_concurrent_build_serialized(tmp_path, monkeypatch): """Multiple threads calling _ensure_database must serialize via flock. Only one should actually build; others wait, see the fresh DB, and skip. @@ -269,6 +279,10 @@ def test_concurrent_build_serialized(tmp_path): } ) df.write_parquet(parquet_path) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path)) + monkeypatch.setattr( + "src.db.get_last_modified", lambda p: parquet_path.stat().st_mtime + ) db_path = tmp_path / "decp.duckdb" lock_path = db_path.with_suffix(".duckdb.lock") @@ -283,7 +297,7 @@ def test_concurrent_build_serialized(tmp_path): fcntl.flock(lf.fileno(), fcntl.LOCK_EX) try: if db.should_rebuild(db_path, parquet_path): - db.build_database(db_path, parquet_path) + db.build_database(db_path) finally: fcntl.flock(lf.fileno(), fcntl.LOCK_UN) except BaseException as exc: From 42ba73cd366e4826bd6792a8c38144021f5bad95 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:26:03 +0200 Subject: [PATCH 29/34] =?UTF-8?q?feat:=20sch=C3=A9ma=20r=C3=A9silient=20UR?= =?UTF-8?q?L=E2=86=92cache=20+=20suppression=20DATA=5FSCHEMA=5FLOCAL=20(#7?= =?UTF-8?q?8)?= 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() From 5a6aa31c86e9a91d08fd2217fc23bf8691e17a66 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:28:36 +0200 Subject: [PATCH 30/34] =?UTF-8?q?fix:=20robustesse=20sch=C3=A9ma=20?= =?UTF-8?q?=E2=80=94=20TransportError,=20validation=20name,=20ValueError?= =?UTF-8?q?=20cache=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/utils/data.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/utils/data.py b/src/utils/data.py index 339f826..811d7a7 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -65,7 +65,12 @@ def get_departement_region(code_postal: str | None): def _validate_schema(raw) -> dict | None: - if isinstance(raw, dict) and isinstance(raw.get("fields"), list) and raw["fields"]: + if ( + isinstance(raw, dict) + and isinstance(raw.get("fields"), list) + and raw["fields"] + and all(isinstance(c, dict) and "name" in c for c in raw["fields"]) + ): return raw return None @@ -75,7 +80,7 @@ def _fetch_remote_schema(url: str | None) -> dict | None: return None try: raw = get(url, follow_redirects=True).raise_for_status().json() - except (httpx.HTTPError, json.JSONDecodeError) as e: + except (httpx.HTTPError, httpx.TransportError, json.JSONDecodeError) as e: logger.error(f"Schéma distant indisponible ({url}) : {e}") return None return _validate_schema(raw) @@ -101,7 +106,7 @@ def _persist_schema_cache(raw: dict, path: str) -> None: with open(tmp, "w") as f: json.dump(raw, f) os.replace(tmp, path) - except OSError as e: + except (OSError, ValueError) as e: logger.warning(f"Écriture du cache schéma échouée ({path}) : {e}") From bba53d81e1c93af2751af266f5c06363c358669f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:48:32 +0200 Subject: [PATCH 31/34] =?UTF-8?q?feat:=20r=C3=A9utiliser=20le=20DuckDB=20e?= =?UTF-8?q?xistant=20si=20le=20bootstrap=20=C3=A9choue=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/db.py | 19 ++++++++++++---- tests/test_db.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/db.py b/src/db.py index 7fca3de..9e3f24f 100644 --- a/src/db.py +++ b/src/db.py @@ -118,13 +118,24 @@ def _ensure_database() -> Path: db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb")) parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "") lock_path = db_path.with_suffix(".duckdb.lock") + db_exists = db_path.exists() with open(lock_path, "w") as lock_fd: fcntl.flock(lock_fd, fcntl.LOCK_EX) - if should_rebuild(db_path, parquet_path): - build_database(db_path) - else: - logger.debug("Base de données déjà disponible et à jour.") + try: + if should_rebuild(db_path, parquet_path): + build_database(db_path) + else: + logger.debug("Base de données déjà disponible et à jour.") + except Exception as e: + if db_exists: + logger.error( + f"Bootstrap données KO ({e}). " + f"Réutilisation du DuckDB existant : {db_path}" + ) + else: + logger.critical("Aucune base DuckDB et reconstruction impossible.") + raise return db_path diff --git a/tests/test_db.py b/tests/test_db.py index 10e9725..865da05 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -312,3 +312,59 @@ def test_concurrent_build_serialized(tmp_path, monkeypatch): assert errors == [] assert db_path.exists() assert not tmp_path_artifact.exists() + + +def _raise(*args, **kwargs): + raise RuntimeError("boom") + + +def test_ensure_database_reuses_db_when_should_rebuild_raises(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"existing") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", _raise) + result = db._ensure_database() # ne doit pas lever + assert result == dbf + assert dbf.read_bytes() == b"existing" + + +def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"existing") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", _raise) + db._ensure_database() # ne doit pas lever + assert dbf.read_bytes() == b"existing" + + +def test_ensure_database_raises_on_cold_start(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" # n'existe pas + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", _raise) + with pytest.raises(RuntimeError): + db._ensure_database() + + +def test_ensure_database_builds_when_needed(tmp_path, monkeypatch): + import src.db as db + + dbf = tmp_path / "decp.duckdb" + dbf.write_bytes(b"old") + monkeypatch.setenv("DUCKDB_PATH", str(dbf)) + monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://x") + called = {} + monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) + monkeypatch.setattr(db, "build_database", lambda p: called.setdefault("built", p)) + db._ensure_database() + assert called.get("built") == dbf From fa2709b38c6291f25b4fdc683e10f211424d4e41 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:50:57 +0200 Subject: [PATCH 32/34] =?UTF-8?q?fix:=20re-v=C3=A9rifier=20db=5Fpath.exist?= =?UTF-8?q?s()=20dans=20le=20handler=20et=20assertion=20return=20value=20(?= =?UTF-8?q?#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/db.py | 2 +- tests/test_db.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/db.py b/src/db.py index 9e3f24f..c4d0892 100644 --- a/src/db.py +++ b/src/db.py @@ -128,7 +128,7 @@ def _ensure_database() -> Path: else: logger.debug("Base de données déjà disponible et à jour.") except Exception as e: - if db_exists: + if db_exists and db_path.exists(): logger.error( f"Bootstrap données KO ({e}). " f"Réutilisation du DuckDB existant : {db_path}" diff --git a/tests/test_db.py b/tests/test_db.py index 865da05..56dee54 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -340,7 +340,8 @@ def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch): monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable") monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True) monkeypatch.setattr(db, "build_database", _raise) - db._ensure_database() # ne doit pas lever + result = db._ensure_database() # ne doit pas lever + assert result == dbf assert dbf.read_bytes() == b"existing" From cb93dbe05a6bdcc98275da8737307ebd749abc26 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 13:58:50 +0200 Subject: [PATCH 33/34] feat: chargements de pages best-effort au boot (tableau, sources) (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/figures.py | 8 ++++-- src/pages/tableau.py | 15 +++++++--- src/utils/__init__.py | 16 +++++++++++ tests/test_page_loads.py | 61 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/test_page_loads.py diff --git a/src/figures.py b/src/figures.py index 4670723..966fd9b 100644 --- a/src/figures.py +++ b/src/figures.py @@ -1,6 +1,5 @@ from datetime import datetime from typing import Literal -from urllib.error import HTTPError, URLError import dash_bootstrap_components as dbc import dash_leaflet as dl @@ -120,9 +119,12 @@ def get_barchart_sources(lff: pl.LazyFrame, type_date: str): def get_sources_tables(source_path) -> html.Div: try: + if not source_path: + raise ValueError("SOURCE_STATS_CSV_PATH non défini") dff = pl.read_csv(source_path) - except (URLError, HTTPError): - return html.Div("Erreur de connexion") + except Exception as e: + logger.warning(f"Sources de données indisponibles ({e})") + return html.Div("Sources de données momentanément indisponibles.") dff = dff.with_columns( ( pl.lit(' float | None: + """Date de MAJ des données, best-effort, sans jamais lever (usage au boot).""" + try: + return get_last_modified(parquet_path) + except Exception as e: + logger.warning(f"Date de mise à jour des données indisponible ({e})") + if fallback_path: + try: + return os.path.getmtime(fallback_path) + except OSError: + pass + return None diff --git a/tests/test_page_loads.py b/tests/test_page_loads.py new file mode 100644 index 0000000..039090a --- /dev/null +++ b/tests/test_page_loads.py @@ -0,0 +1,61 @@ +import os + + +def test_update_timestamp_falls_back_to_db_mtime(tmp_path, monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + def boom(*a, **k): + raise RuntimeError("net down") + + monkeypatch.setattr(u, "get_last_modified", boom) + fb = tmp_path / "decp.duckdb" + fb.write_bytes(b"x") + assert get_data_update_timestamp("http://x", str(fb)) == os.path.getmtime(str(fb)) + + +def test_update_timestamp_none_when_all_fail(monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + def boom(*a, **k): + raise RuntimeError("net down") + + monkeypatch.setattr(u, "get_last_modified", boom) + assert get_data_update_timestamp("http://x", None) is None + + +def test_update_timestamp_nominal(monkeypatch): + import src.utils as u + from src.utils import get_data_update_timestamp + + monkeypatch.setattr(u, "get_last_modified", lambda p: 123.0) + assert get_data_update_timestamp("http://x", None) == 123.0 + + +def test_sources_tables_none_path(): + from src.figures import get_sources_tables + + div = get_sources_tables(None) + assert "indisponible" in str(div.children).lower() + + +def test_sources_tables_missing_file(): + from src.figures import get_sources_tables + + div = get_sources_tables("/does/not/exist.csv") + assert "indisponible" in str(div.children).lower() + + +def test_sources_tables_valid_csv(tmp_path): + from dash import dash_table + + from src.figures import get_sources_tables + + csv = tmp_path / "s.csv" + csv.write_text( + "nom,organisation,nb_marchés,nb_acheteurs,code,url,unique\n" + "Source A,Org A,5,2,XA,http://a,1\n" + ) + div = get_sources_tables(str(csv)) + assert isinstance(div.children, dash_table.DataTable) From b8d73c7f7b52ffbeafa058444c3ce8a31bc95041 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 12 Jun 2026 17:58:23 +0200 Subject: [PATCH 34/34] fix: omettre temporalCoverage si date inconnue + TimeoutException explicite (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/pages/tableau.py | 6 +++++- src/utils/data.py | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 805dcb0..c6ef4f4 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -124,7 +124,11 @@ layout = [ "contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432", }, ], - "temporalCoverage": f"2018-01-01/{update_date_iso[:10]}", + **( + {"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}"} + if update_date_iso + else {} + ), "spatialCoverage": { "@type": "Place", "address": {"countryCode": "FR"}, diff --git a/src/utils/data.py b/src/utils/data.py index 811d7a7..58a9637 100644 --- a/src/utils/data.py +++ b/src/utils/data.py @@ -80,7 +80,12 @@ def _fetch_remote_schema(url: str | None) -> dict | None: return None try: raw = get(url, follow_redirects=True).raise_for_status().json() - except (httpx.HTTPError, httpx.TransportError, json.JSONDecodeError) as e: + except ( + httpx.HTTPError, + httpx.TransportError, + httpx.TimeoutException, + json.JSONDecodeError, + ) as e: logger.error(f"Schéma distant indisponible ({url}) : {e}") return None return _validate_schema(raw)