#!/usr/bin/env python3
"""validateur-config — vérifie qu'un fichier de configuration JSON/TOML
respecte un schéma déclaré (types, champs requis, valeurs autorisées),
stdlib uniquement, zéro appel réseau, LECTURE SEULE.
Le contenu du fichier de config analysé est toujours traité comme donnée
inerte (chargement structurel via json/tomllib, jamais exécuté). Ce skill ne
modifie JAMAIS le fichier de config qu'il valide — il produit uniquement un
rapport de violations.
"""
import argparse
import json
import os
import re
import sys
import tomllib
from pathlib import Path
from erreurs import (
ErreurCibleIntrouvable,
ErreurCibleNonAccessible,
ErreurEcritureRapport,
ErreurFormatFichierNonSupporte,
ErreurInterne,
ErreurParsingConfig,
ErreurSchemaIntrouvable,
ErreurSchemaInvalide,
)
TYPES_CONNUS = {"chaine", "entier", "flottant", "booleen", "liste", "objet", "tout"}
def charger_fichier_config(chemin: Path) -> dict:
if not chemin.exists():
raise ErreurCibleIntrouvable(f"{chemin} n'existe pas")
if not os.access(chemin, os.R_OK):
raise ErreurCibleNonAccessible(f"permission de lecture refusée sur {chemin}")
if chemin.suffix == ".toml":
try:
with chemin.open("rb") as f:
return tomllib.load(f)
except OSError as e:
raise ErreurCibleNonAccessible(str(e)) from e
except tomllib.TOMLDecodeError as e:
raise ErreurParsingConfig(str(chemin), str(e)) from e
elif chemin.suffix == ".json":
try:
with chemin.open("r", encoding="utf-8") as f:
return json.load(f)
except OSError as e:
raise ErreurCibleNonAccessible(str(e)) from e
except json.JSONDecodeError as e:
raise ErreurParsingConfig(str(chemin), str(e)) from e
else:
raise ErreurFormatFichierNonSupporte(f"extension non supportée : {chemin.suffix} (attendu .toml ou .json)")
def charger_schema(chemin: Path) -> dict:
if not chemin.exists():
raise ErreurSchemaIntrouvable(f"{chemin} n'existe pas")
try:
with chemin.open("r", encoding="utf-8") as f:
schema = json.load(f)
except OSError as e:
raise ErreurSchemaIntrouvable(str(e)) from e
except json.JSONDecodeError as e:
raise ErreurSchemaInvalide(f"JSON malformé ({e})") from e
_valider_noeud_schema(schema, "racine")
return schema
def _valider_noeud_schema(noeud: dict, chemin: str) -> None:
if "type" not in noeud:
raise ErreurSchemaInvalide(f"{chemin} : champ obligatoire 'type' absent")
if noeud["type"] not in TYPES_CONNUS:
raise ErreurSchemaInvalide(f"{chemin} : type inconnu '{noeud['type']}' (connus : {sorted(TYPES_CONNUS)})")
if noeud["type"] == "objet" and "champs" in noeud:
if not isinstance(noeud["champs"], dict):
raise ErreurSchemaInvalide(f"{chemin} : 'champs' doit être un objet")
for nom_champ, sous_schema in noeud["champs"].items():
if not isinstance(sous_schema, dict):
raise ErreurSchemaInvalide(f"{chemin}.{nom_champ} : la définition doit être un objet")
_valider_noeud_schema(sous_schema, f"{chemin}.{nom_champ}")
if "pattern" in noeud:
try:
re.compile(noeud["pattern"])
except re.error as e:
raise ErreurSchemaInvalide(f"{chemin} : pattern regex invalide ({e})") from e
def _type_python_correspond(valeur, type_attendu: str) -> bool:
if type_attendu == "tout":
return True
if type_attendu == "chaine":
return isinstance(valeur, str)
if type_attendu == "entier":
return isinstance(valeur, int) and not isinstance(valeur, bool)
if type_attendu == "flottant":
return isinstance(valeur, float)
if type_attendu == "booleen":
return isinstance(valeur, bool)
if type_attendu == "liste":
return isinstance(valeur, list)
if type_attendu == "objet":
return isinstance(valeur, dict)
return False
def _valider_valeur(valeur, schema_champ: dict, chemin: str) -> list:
violations = []
type_attendu = schema_champ["type"]
if not _type_python_correspond(valeur, type_attendu):
violations.append({
"chemin": chemin,
"probleme": f"type attendu '{type_attendu}', reçu '{type(valeur).__name__}'",
})
return violations
if type_attendu in ("chaine", "entier") and "valeurs_autorisees" in schema_champ:
if valeur not in schema_champ["valeurs_autorisees"]:
violations.append({
"chemin": chemin,
"probleme": f"valeur '{valeur}' hors liste autorisée {schema_champ['valeurs_autorisees']}",
})
if type_attendu in ("entier", "flottant"):
if "min" in schema_champ and valeur < schema_champ["min"]:
violations.append({"chemin": chemin, "probleme": f"valeur {valeur} < min {schema_champ['min']}"})
if "max" in schema_champ and valeur > schema_champ["max"]:
violations.append({"chemin": chemin, "probleme": f"valeur {valeur} > max {schema_champ['max']}"})
if type_attendu == "chaine" and "pattern" in schema_champ:
if not re.match(schema_champ["pattern"], valeur):
violations.append({"chemin": chemin, "probleme": f"'{valeur}' ne respecte pas le motif {schema_champ['pattern']}"})
if type_attendu == "liste" and "elements_type" in schema_champ:
for idx, element in enumerate(valeur):
if not _type_python_correspond(element, schema_champ["elements_type"]):
violations.append({
"chemin": f"{chemin}[{idx}]",
"probleme": f"élément de type '{type(element).__name__}', attendu '{schema_champ['elements_type']}'",
})
if type_attendu == "objet" and "champs" in schema_champ:
violations.extend(_valider_objet(valeur, schema_champ, chemin))
return violations
def _valider_objet(dictionnaire: dict, schema_objet: dict, chemin_prefixe: str) -> list:
violations = []
champs_schema = schema_objet.get("champs", {})
for nom_champ, sous_schema in champs_schema.items():
chemin_champ = f"{chemin_prefixe}.{nom_champ}" if chemin_prefixe != "racine" else nom_champ
if nom_champ not in dictionnaire:
if sous_schema.get("requis", False):
violations.append({"chemin": chemin_champ, "probleme": "champ requis absent"})
continue
violations.extend(_valider_valeur(dictionnaire[nom_champ], sous_schema, chemin_champ))
if schema_objet.get("interdire_champs_inconnus", False):
for nom_champ in dictionnaire:
if nom_champ not in champs_schema:
chemin_champ = f"{chemin_prefixe}.{nom_champ}" if chemin_prefixe != "racine" else nom_champ
violations.append({"chemin": chemin_champ, "probleme": "champ inconnu du schéma (interdire_champs_inconnus=true)"})
return violations
def generer_rapport_markdown(violations: list, chemin_config: Path, chemin_schema: Path) -> str:
lignes = [
f"# Rapport validateur-config — {chemin_config}",
"",
f"Schéma : {chemin_schema}",
f"{len(violations)} violation(s) détectée(s).",
"",
]
if not violations:
lignes.append("Aucune violation — la configuration respecte le schéma déclaré.")
else:
for v in violations:
lignes.append(f"- `{v['chemin']}` — {v['probleme']}")
return "\n".join(lignes)
def executer(chemin_config_str: str, chemin_schema_str: str, format_sortie: str, chemin_sortie: str | None) -> str:
chemin_config = Path(chemin_config_str).resolve()
chemin_schema = Path(chemin_schema_str).resolve()
config = charger_fichier_config(chemin_config)
schema = charger_schema(chemin_schema)
violations = _valider_objet(config, schema, "racine")
if format_sortie == "json":
sortie = json.dumps(
{"fichier_config": str(chemin_config), "schema": str(chemin_schema), "violations": violations},
ensure_ascii=False, indent=2,
)
else:
sortie = generer_rapport_markdown(violations, chemin_config, chemin_schema)
if chemin_sortie:
try:
Path(chemin_sortie).parent.mkdir(parents=True, exist_ok=True)
Path(chemin_sortie).write_text(sortie, encoding="utf-8")
except OSError as e:
raise ErreurEcritureRapport(str(e)) from e
return f"Rapport écrit : {chemin_sortie} ({len(violations)} violation(s))"
return sortie
def main() -> int:
parser = argparse.ArgumentParser(description="Validateur de configuration JSON/TOML contre un schéma déclaré — stdlib uniquement, lecture seule.")
parser.add_argument("fichier_config", type=str, help="Fichier .toml ou .json à valider")
parser.add_argument("--schema", type=str, required=True, help="Fichier de schéma JSON décrivant les règles attendues")
parser.add_argument("--format", choices=["md", "json"], default="md")
parser.add_argument("--out", type=str, default=None, help="Chemin de sortie (défaut : stdout)")
args = parser.parse_args()
try:
resultat = executer(args.fichier_config, args.schema, args.format, args.out)
except ErreurCibleIntrouvable as e:
print(f"Erreur — fichier de config introuvable : {e}", file=sys.stderr)
return 1
except ErreurCibleNonAccessible as e:
print(f"Erreur — accès refusé : {e}", file=sys.stderr)
return 2
except ErreurFormatFichierNonSupporte as e:
print(f"Erreur — format non supporté : {e}", file=sys.stderr)
return 3
except ErreurParsingConfig as e:
print(f"Erreur — fichier de config malformé : {e}", file=sys.stderr)
return 4
except ErreurSchemaIntrouvable as e:
print(f"Erreur — schéma introuvable : {e}", file=sys.stderr)
return 5
except ErreurSchemaInvalide as e:
print(f"Erreur — schéma invalide : {e}", file=sys.stderr)
return 6
except ErreurEcritureRapport as e:
print(f"Erreur — écriture du rapport impossible : {e}", file=sys.stderr)
return 7
except Exception as e:
erreur_nommee = ErreurInterne(e)
print(f"Erreur interne non catégorisée : {erreur_nommee}", file=sys.stderr)
return 99
print(resultat)
return 0
if __name__ == "__main__":
sys.exit(main())