"""Validateur JSON-Schema draft-07 (sous-ensemble) · zéro dépendance pip. Pourquoi un validateur maison plutôt que `jsonschema` ? ----------------------------------------------------- Le gate CI (Gitea Actions, cf. .gitea/workflows/ci.yml) tourne sur un runner `act_runner` sans installation `pip` (les guards Sprint 1 sont du bash pur). Ce validateur couvre EXACTEMENT les constructions utilisées par les deux schémas du contrat Faisabilité↔Publiciste (`version.schema.json`, `projets_master.schema.json`) : type, required, additionalProperties, enum, pattern, minLength, minItems, maxItems, uniqueItems, minimum, maximum, items, properties, $ref (interne « #/definitions/... »), allOf, if/then, const, format. Les tests (`tests/test_publiciste.py`) utilisent en plus la bibliothèque `jsonschema` comme oracle *quand elle est disponible*, pour se prémunir d'un écart entre ce validateur et la spec draft-07. En production le validateur maison suffit et reste autonome. API : validate(instance, schema) -> list[str] (liste d'erreurs, vide si OK). """ from __future__ import annotations import re from typing import Any class SchemaError(Exception): """Schéma mal formé (bug du schéma, pas de la donnée).""" def _resolve_ref(ref: str, root: dict) -> dict: if not ref.startswith("#/"): raise SchemaError(f"$ref non supporté (interne uniquement) : {ref}") node: Any = root for part in ref[2:].split("/"): part = part.replace("~1", "/").replace("~0", "~") node = node[part] return node def _type_ok(value: Any, expected: str) -> bool: if expected == "object": return isinstance(value, dict) if expected == "array": return isinstance(value, list) if expected == "string": return isinstance(value, str) if expected == "integer": # bool est un int en Python — on l'exclut explicitement. return isinstance(value, int) and not isinstance(value, bool) if expected == "number": return isinstance(value, (int, float)) and not isinstance(value, bool) if expected == "boolean": return isinstance(value, bool) if expected == "null": return value is None raise SchemaError(f"type inconnu dans le schéma : {expected}") # Formats vérifiés (les autres sont acceptés sans contrôle, comme le veut draft-07). _DATE_TIME = re.compile( r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$" ) def _check_format(value: str, fmt: str, path: str, errors: list[str]) -> None: if fmt == "date-time" and not _DATE_TIME.match(value): errors.append(f"{path}: format date-time invalide ({value!r})") def _validate(value: Any, schema: dict, root: dict, path: str, errors: list[str]) -> None: if "$ref" in schema: schema = _resolve_ref(schema["$ref"], root) # const if "const" in schema and value != schema["const"]: errors.append(f"{path}: attendu const={schema['const']!r}, reçu {value!r}") # enum if "enum" in schema and value not in schema["enum"]: errors.append(f"{path}: {value!r} hors enum {schema['enum']}") # type (peut être une liste de types alternatifs) if "type" in schema: types = schema["type"] types = [types] if isinstance(types, str) else types if not any(_type_ok(value, t) for t in types): errors.append(f"{path}: type {type(value).__name__} ∉ {types}") # Inutile d'aller plus loin si le type de base est faux. return if isinstance(value, str): if "minLength" in schema and len(value) < schema["minLength"]: errors.append(f"{path}: chaîne trop courte (min {schema['minLength']})") if "pattern" in schema and not re.search(schema["pattern"], value): errors.append(f"{path}: {value!r} ne matche pas /{schema['pattern']}/") if "format" in schema: _check_format(value, schema["format"], path, errors) if isinstance(value, (int, float)) and not isinstance(value, bool): if "minimum" in schema and value < schema["minimum"]: errors.append(f"{path}: {value} < minimum {schema['minimum']}") if "maximum" in schema and value > schema["maximum"]: errors.append(f"{path}: {value} > maximum {schema['maximum']}") if isinstance(value, list): if "minItems" in schema and len(value) < schema["minItems"]: errors.append(f"{path}: {len(value)} items < minItems {schema['minItems']}") if "maxItems" in schema and len(value) > schema["maxItems"]: errors.append(f"{path}: {len(value)} items > maxItems {schema['maxItems']}") if schema.get("uniqueItems") and _has_duplicates(value): errors.append(f"{path}: items non uniques") if "items" in schema: for i, item in enumerate(value): _validate(item, schema["items"], root, f"{path}[{i}]", errors) if isinstance(value, dict): props = schema.get("properties", {}) for req in schema.get("required", []): if req not in value: errors.append(f"{path}: propriété requise absente « {req} »") if schema.get("additionalProperties") is False: extra = set(value) - set(props) if extra: errors.append(f"{path}: propriétés interdites {sorted(extra)}") for key, sub in props.items(): if key in value: _validate(value[key], sub, root, f"{path}.{key}", errors) # Combinateurs for sub in schema.get("allOf", []): _validate(value, sub, root, path, errors) if "if" in schema: cond_errors: list[str] = [] _validate(value, schema["if"], root, path, cond_errors) branch = "then" if not cond_errors else "else" if branch in schema: _validate(value, schema[branch], root, path, errors) def _has_duplicates(items: list) -> bool: seen: list = [] for it in items: if it in seen: return True seen.append(it) return False def validate(instance: Any, schema: dict) -> list[str]: """Valide `instance` contre `schema`. Retourne la liste des erreurs (vide = OK).""" errors: list[str] = [] _validate(instance, schema, schema, "$", errors) return errors def is_valid(instance: Any, schema: dict) -> bool: return not validate(instance, schema)