9b124deeea
- Rename project from decp.info to colibre across all codebase - Update domain from https://decp.info to https://colibre.fr - Update GitHub repo references to ColinMaudry/colibre - Rename deployment files: decpinfo-backup.* → colibre-backup.* - Update project configuration and documentation - Rename project assets: decp.info.png → colibre.png - Update environment variables and constants (DOMAIN_NAME, TOKEN_PREFIX, GITHUB_REPO, etc.) - Update URLs in all pages, tests, and configuration files - Keep DECP acronym in text (unchanged per requirements) - Add rebrand note to README.md Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
288 lines
9.0 KiB
Python
288 lines
9.0 KiB
Python
#!/usr/bin/env python
|
|
"""Benchmark comparatif de l'endpoint /data : colibre vs data.gouv.fr.
|
|
|
|
Les deux APIs partagent le même schéma de requête (mêmes opérateurs), donc
|
|
chaque scénario est envoyé à l'identique aux deux et les temps de réponse
|
|
sont comparés côte à côte.
|
|
|
|
Usage :
|
|
python tests/api/benchmark.py --token colibre_xxx
|
|
python tests/api/benchmark.py --url http://localhost:8050/api/v1/data --token colibre_xxx
|
|
python tests/api/benchmark.py --decp-only --token colibre_xxx --runs 20
|
|
|
|
Par défaut, --url pointe vers la production colibre.fr ; data.gouv.fr est
|
|
interrogé sans authentification.
|
|
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from urllib.parse import quote
|
|
|
|
import httpx
|
|
|
|
DECP_DEFAULT_URL = "https://colibre.fr/api/v1/data"
|
|
DATAGOUV_DEFAULT_URL = (
|
|
"https://tabular-api.data.gouv.fr/api/resources/"
|
|
"22847056-61df-452d-837d-8b8ceadbfc52/data/"
|
|
)
|
|
|
|
# Chaque scénario : liste de (clé, valeur). valeur=None → drapeau nu (sans `=`),
|
|
# requis par data.gouv.fr pour les opérateurs d'agrégation et isnull.
|
|
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 __differs sur département",
|
|
"params": [("acheteur_departement_code__differs", "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": [("codeCPV__in", "72000000,72200000"), ("page_size", "50")],
|
|
},
|
|
{
|
|
"name": "filtre __isnull sur montant",
|
|
"params": [("montant__isnull", None), ("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": "agrégation groupby + count",
|
|
"params": [
|
|
("acheteur_departement_code__groupby", None),
|
|
("uid__count", None),
|
|
("page_size", "100"),
|
|
],
|
|
},
|
|
{
|
|
"name": "agrégation groupby + sum + avg",
|
|
"params": [
|
|
("acheteur_departement_code__groupby", None),
|
|
("montant__sum", None),
|
|
("montant__avg", None),
|
|
("page_size", "100"),
|
|
],
|
|
},
|
|
{
|
|
"name": "page 2",
|
|
"params": [("page", "2"), ("page_size", "50")],
|
|
},
|
|
{
|
|
"name": "count_results=false (optim COUNT(*))",
|
|
"params": [("page_size", "50"), ("count_results", "false")],
|
|
"decp_only": True,
|
|
},
|
|
]
|
|
|
|
COL_NAME = 40
|
|
COL_STAT = 9
|
|
|
|
|
|
@dataclass
|
|
class Target:
|
|
label: str
|
|
base_url: str
|
|
headers: dict = field(default_factory=dict)
|
|
|
|
|
|
def build_query(params: list[tuple[str, str | None]]) -> str:
|
|
"""Construit la query string. valeur=None → clé nue (sans `=`)."""
|
|
parts = []
|
|
for key, value in params:
|
|
if value is None:
|
|
parts.append(key)
|
|
else:
|
|
parts.append(f"{key}={quote(str(value), safe=',:')}")
|
|
return "&".join(parts)
|
|
|
|
|
|
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 measure(target: Target, query: str, runs: int) -> dict | None:
|
|
"""Chauffe (1 requête non mesurée) puis chronomètre `runs` requêtes."""
|
|
url = f"{target.base_url}?{query}"
|
|
try:
|
|
warm = httpx.get(url, headers=target.headers, timeout=30)
|
|
last_status = warm.status_code
|
|
except httpx.RequestError as exc:
|
|
return {"error": str(exc), "status": 0}
|
|
|
|
timings: list[float] = []
|
|
for _ in range(runs):
|
|
try:
|
|
t0 = time.perf_counter()
|
|
resp = httpx.get(url, headers=target.headers, timeout=30)
|
|
timings.append((time.perf_counter() - t0) * 1000)
|
|
last_status = resp.status_code
|
|
except httpx.RequestError as exc:
|
|
return {"error": str(exc), "status": 0}
|
|
|
|
return {
|
|
"status": last_status,
|
|
"median": percentile(timings, 50),
|
|
"p95": percentile(timings, 95),
|
|
"min": min(timings),
|
|
"max": max(timings),
|
|
}
|
|
|
|
|
|
def run_benchmark(targets: list[Target], colibre_label: str, runs: int) -> None:
|
|
print("\nAVERTISSEMENT : volumes de données différents entre les deux APIs.")
|
|
print(f"Scénarios : {len(SCENARIOS)} | Répétitions : {runs}\n")
|
|
|
|
rows: list[dict] = []
|
|
for scenario in SCENARIOS:
|
|
query = build_query(scenario["params"])
|
|
decp_only = scenario.get("decp_only", False)
|
|
active = [t for t in targets if not (decp_only and t.label != colibre_label)]
|
|
|
|
measures = {t.label: measure(t, query, runs) for t in active}
|
|
rows.append({"name": scenario["name"], "measures": measures})
|
|
|
|
bits = []
|
|
for t in active:
|
|
m = measures[t.label]
|
|
if "error" in m:
|
|
bits.append(f"{t.label}: ERREUR")
|
|
else:
|
|
bits.append(f"{t.label}: méd {m['median']:.0f}ms [{m['status']}]")
|
|
print(f" {scenario['name'][:COL_NAME]:<{COL_NAME}} " + " | ".join(bits))
|
|
|
|
_print_summary(rows, targets, colibre_label)
|
|
|
|
|
|
def _fmt(m: dict | None, key: str) -> str:
|
|
if m is None:
|
|
return "—"
|
|
if "error" in m:
|
|
return "ERR"
|
|
return f"{m[key]:.0f}"
|
|
|
|
|
|
def _print_summary(rows: list[dict], targets: list[Target], colibre_label: str) -> None:
|
|
dg = next((t.label for t in targets if t.label != colibre_label), None)
|
|
|
|
header = (
|
|
f"{'Scénario':<{COL_NAME}}"
|
|
f" {'DG méd':>{COL_STAT}} {'DG p95':>{COL_STAT}}"
|
|
f" {'decp méd':>{COL_STAT}} {'decp p95':>{COL_STAT}}"
|
|
f" {'ratio':>7}"
|
|
)
|
|
sep = "-" * len(header)
|
|
print(
|
|
f"\n{'=' * len(header)}\nRÉSUMÉ (ratio = decp / data.gouv.fr, <1 = decp plus rapide)"
|
|
)
|
|
print(f"{'=' * len(header)}\n{header}\n{sep}")
|
|
|
|
for row in rows:
|
|
m_colibre = row["measures"].get(colibre_label)
|
|
m_dg = row["measures"].get(dg) if dg else None
|
|
|
|
ratio = "—"
|
|
if m_colibre and m_dg and "error" not in m_colibre and "error" not in m_dg:
|
|
if m_dg["median"] > 0:
|
|
ratio = f"{m_colibre['median'] / m_dg['median']:.2f}"
|
|
|
|
print(
|
|
f"{row['name'][:COL_NAME]:<{COL_NAME}}"
|
|
f" {_fmt(m_dg, 'median'):>{COL_STAT}} {_fmt(m_dg, 'p95'):>{COL_STAT}}"
|
|
f" {_fmt(m_colibre, 'median'):>{COL_STAT}} {_fmt(m_colibre, 'p95'):>{COL_STAT}}"
|
|
f" {ratio:>7}"
|
|
)
|
|
print(sep)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Benchmark comparatif colibre vs data.gouv.fr (/data)"
|
|
)
|
|
parser.add_argument(
|
|
"--url",
|
|
default=DECP_DEFAULT_URL,
|
|
help=f"Endpoint /data de colibre (défaut : {DECP_DEFAULT_URL})",
|
|
)
|
|
parser.add_argument(
|
|
"--datagouv-url",
|
|
default=DATAGOUV_DEFAULT_URL,
|
|
help="Endpoint /data/ de la ressource data.gouv.fr",
|
|
)
|
|
parser.add_argument(
|
|
"--token",
|
|
default=None,
|
|
help="Token Bearer colibre (format decpinfo_xxx)",
|
|
)
|
|
parser.add_argument(
|
|
"--runs",
|
|
type=int,
|
|
default=5,
|
|
help="Répétitions chronométrées par scénario (défaut : 5)",
|
|
)
|
|
parser.add_argument(
|
|
"--decp-only",
|
|
action="store_true",
|
|
help="Ne benchmarker que colibre (saute data.gouv.fr)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.runs < 1:
|
|
print("--runs doit être ≥ 1", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
colibre_label = "colibre"
|
|
decp_headers = {"Authorization": f"Bearer {args.token}"} if args.token else {}
|
|
decp = Target(label=colibre_label, base_url=args.url, headers=decp_headers)
|
|
|
|
targets = [decp]
|
|
if not args.decp_only:
|
|
# data.gouv.fr d'abord pour l'affichage côte à côte
|
|
targets.insert(0, Target(label="data.gouv.fr", base_url=args.datagouv_url))
|
|
|
|
run_benchmark(targets, colibre_label, args.runs)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|