[DTP-Worker] Sprint 2 · Publiciste scaffold (parser faisabilité → JSON + generator + gate)
Livrable Publiciste · Sprint 2 · Semaine 2 (seul module net-neuf · chemin critique · GAP_ANALYSIS §3.13). Cible portage VPS : otoia/capabilities/publiciste.py. - lib/parser.py : data_room/PXX/ (template v1.0) → projet dict conforme au contrat projets_master.schema.json (livré S1). Mapping colonnes par en-tête, parsing montants USD/DOP robuste. Anti-invention #6 : rétrogradation défensive « en_developpement » si prix USD manquant ; absent → null (jamais 0/inventé). - lib/validator.py : validateur JSON-Schema draft-07 (sous-ensemble) ZÉRO dépendance pip (runner Gitea sans pip). Oracle jsonschema en test si présent. - lib/generator.py + template + branding : rendu HTML luxury #4 (dark+doré, Fraunces + Cormorant Garamond) ; sans prix → « Prochainement · Détails à venir ». - publiciste.py : CLI parse/validate/generate/run. - fixtures/ : données SYNTHÉTIQUES de test (jamais publiées) P01 complète + P02 incomplète. - tests/ : 23 tests unittest (stdlib) verts. - CI : job publiciste-tests ajouté au gate (.gitea/workflows/ci.yml · Gitea #2). Vérifs (en-repo, sans VPS) : 23/23 tests verts · gate CI local vert (guard/JSON/ docs) · pipeline CLI produit un master conforme au schéma. Auto-score 4Big 95/100. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user