etm-powersync-energy-plugin.../tools/etm-powersync-schema-check
Patrick Schurig fb581218c8 feat(outils): etm-powersync-schema-check, validateur du contrat OpenRPC
Python, bibliothèque standard seule. Sous-ensemble de JSON Schema déclaré ;
un mot-clé non géré est une erreur de schéma, jamais ignoré. Valide un
message, une session NDJSON ou des traces ; --semantic applique
x-etm-semantic-rules dans l'ordre du validateur du moteur (une règle sans
implantation, ou l'inverse, est une erreur).

Test : références du sérialiseur et traces réelles du moteur conformes ;
optimiseur de référence mode par mode, et qui rejette chaque mode mauvais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:15:51 +02:00

673 lines
30 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
"""Valide des messages du protocole moteur ↔ optimiseur contre son contrat OpenRPC.
etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json
etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json
[--request PARAMS.json] [--handshake RESULT.json] [--semantic]
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic] [--requests-only]
etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic]
etm-powersync-schema-check --subset
Le schéma par défaut est `docs/etm-powersync-optimizer-protocol.openrpc.json` du dépôt. Aucun
accès réseau ; bibliothèque standard seule.
SOUS-ENSEMBLE DE JSON SCHEMA géré (le schéma n'en utilise pas d'autre) : `$ref` (local, `#/…`),
`type` (dont `integer` : 3.0 en est un), `enum`, `const`, `properties`, `required`,
`additionalProperties` (booléen ou schéma), `propertyNames`, `items` (un schéma), `minItems`,
`maxItems`, `minimum`, `maximum`, `exclusiveMinimum`, `minLength`, `maxLength` (en caractères),
`pattern` (recherche, non ancrée), `oneOf`, `anyOf`, `allOf`. Annotations lues sans effet :
`title`, `description`, `examples`, `default`, `deprecated`, `$comment`, et toute clé `x-…`.
**Tout autre mot-clé est une ERREUR de schéma (code 2), jamais ignoré en silence** : un mot-clé
qu'on croirait appliqué et qui ne l'est pas ferait passer pour valide ce qu'il devait refuser.
Ce que l'outil lit en plus du JSON Schema :
- les `params` d'une méthode `by-name` forment un objet FERMÉ aux noms déclarés (le moteur les
émet : un nom de plus est un champ ajouté sans le contrat), sauf `x-etm-params-open: true` ;
- `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ;
- `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de
la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou
l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence.
Modes :
- `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est
rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier
`Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes).
- `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de
`GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur
(`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur.
Sortie : une ligne par écart, puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un
verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma.
Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises.
"""
import argparse
import json
import math
import os
import re
import sys
import unicodedata
from datetime import datetime
ICI = os.path.dirname(os.path.abspath(__file__))
SCHEMA_DEFAUT = os.path.join(ICI, '..', 'docs', 'etm-powersync-optimizer-protocol.openrpc.json')
GERES = {'$ref', 'type', 'enum', 'const', 'properties', 'required', 'additionalProperties',
'propertyNames', 'items', 'minItems', 'maxItems', 'minimum', 'maximum',
'exclusiveMinimum', 'minLength', 'maxLength', 'pattern', 'oneOf', 'anyOf', 'allOf'}
ANNOTATIONS = {'title', 'description', 'examples', 'default', 'deprecated', '$comment'}
TYPES = {'null', 'boolean', 'object', 'array', 'number', 'integer', 'string'}
class ErreurSchema(Exception):
"""Le schéma lui-même est inutilisable (mot-clé non géré, $ref cassé, règle orpheline)."""
# --- Validation JSON Schema (sous-ensemble) -------------------------------------------------------
def est_nombre(v):
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
def a_le_type(v, t):
if t == 'null':
return v is None
if t == 'boolean':
return isinstance(v, bool)
if t == 'object':
return isinstance(v, dict)
if t == 'array':
return isinstance(v, list)
if t == 'string':
return isinstance(v, str)
if t == 'number':
return est_nombre(v)
if t == 'integer':
return est_nombre(v) and float(v).is_integer()
return False
def egal_json(a, b):
"""Égalité JSON : 1 == 1.0, mais true n'est pas 1."""
if isinstance(a, bool) or isinstance(b, bool):
return isinstance(a, bool) and isinstance(b, bool) and a == b
if est_nombre(a) and est_nombre(b):
return a == b
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(egal_json(x, y) for x, y in zip(a, b))
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(egal_json(a[k], b[k]) for k in a)
return type(a) is type(b) and a == b
def court(v):
t = json.dumps(v, ensure_ascii=False)
return t if len(t) <= 60 else t[:57] + '…'
class Validateur:
def __init__(self, doc):
self.doc = doc
self.regex = {}
self.auditer()
# -- audit : chaque mot-clé de chaque sous-schéma est géré, chaque $ref se résout
def auditer(self):
racines = []
for nom, s in (self.doc.get('components', {}).get('schemas', {}) or {}).items():
racines.append((f'#/components/schemas/{nom}', s))
for m in self.doc.get('methods', []):
for p in m.get('params', []):
racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema')))
if 'result' in m:
racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema')))
for ou, s in racines:
self.auditer_schema(s, ou)
def auditer_schema(self, s, ou):
if isinstance(s, bool):
return
if not isinstance(s, dict):
raise ErreurSchema(f'{ou} : un schéma doit être un objet ou un booléen')
for cle, val in s.items():
if cle in ANNOTATIONS or cle.startswith('x-'):
continue
if cle not in GERES:
raise ErreurSchema(f'{ou} : mot-clé « {cle} » non géré par cet outil — refusé plutôt '
f'qu\'ignoré (sous-ensemble : --subset)')
if cle == '$ref':
self.resoudre(val, ou)
elif cle == 'type':
for t in [val] if isinstance(val, str) else val:
if t not in TYPES:
raise ErreurSchema(f'{ou} : type « {t} » inconnu')
elif cle == 'properties':
for k, sous in val.items():
self.auditer_schema(sous, f'{ou}/properties/{k}')
elif cle in ('items', 'propertyNames') or (cle == 'additionalProperties' and isinstance(val, dict)):
self.auditer_schema(val, f'{ou}/{cle}')
elif cle in ('oneOf', 'anyOf', 'allOf'):
for i, sous in enumerate(val):
self.auditer_schema(sous, f'{ou}/{cle}/{i}')
elif cle == 'pattern':
self.motif(val)
def resoudre(self, ref, ou='?'):
if not isinstance(ref, str) or not ref.startswith('#/'):
raise ErreurSchema(f'{ou} : $ref « {ref} » non local')
noeud = self.doc
for morceau in ref[2:].split('/'):
morceau = morceau.replace('~1', '/').replace('~0', '~')
if not isinstance(noeud, dict) or morceau not in noeud:
raise ErreurSchema(f'{ou} : $ref « {ref} » ne se résout pas')
noeud = noeud[morceau]
return noeud
def motif(self, p):
if p not in self.regex:
try:
self.regex[p] = re.compile(p)
except re.error as e:
raise ErreurSchema(f'pattern « {p} » illisible : {e}')
return self.regex[p]
def discriminant_rate(self, v, s):
"""Vrai si une propriété à const/enum de la branche contredit l'instance."""
while isinstance(s, dict) and '$ref' in s:
s = self.resoudre(s['$ref'])
if not isinstance(v, dict) or not isinstance(s, dict):
return False
for k, p in (s.get('properties') or {}).items():
if not isinstance(p, dict) or not ('const' in p or 'enum' in p):
continue
if k not in v:
if k in s.get('required', []):
return True
elif self.valider(v[k], p):
return True
return False
# -- validation : liste d'écarts (chemin, mot-clé, message)
def valider(self, v, s, chemin=''):
if s is True or s == {}:
return []
if s is False:
return [(chemin or '/', 'false', 'aucune valeur permise')]
e = []
if '$ref' in s:
e += self.valider(v, self.resoudre(s['$ref']), chemin)
if 'type' in s:
types = [s['type']] if isinstance(s['type'], str) else s['type']
if not any(a_le_type(v, t) for t in types):
return e + [(chemin or '/', 'type', f'{court(v)} n\'est pas {"|".join(types)}')]
if 'const' in s and not egal_json(v, s['const']):
e.append((chemin or '/', 'const', f'{court(v)} ≠ {court(s["const"])}'))
if 'enum' in s and not any(egal_json(v, x) for x in s['enum']):
e.append((chemin or '/', 'enum', f'{court(v)} hors de {court(s["enum"])}'))
if isinstance(v, dict):
props = s.get('properties', {})
for k in s.get('required', []):
if k not in v:
e.append((chemin or '/', 'required', f'« {k} » manquant'))
for k, x in v.items():
if k in props:
e += self.valider(x, props[k], f'{chemin}/{k}')
elif 'additionalProperties' in s:
ap = s['additionalProperties']
if ap is False:
e.append((f'{chemin}/{k}', 'additionalProperties', f'champ « {k} » non déclaré'))
elif isinstance(ap, dict):
e += self.valider(x, ap, f'{chemin}/{k}')
if 'propertyNames' in s:
e += [(f'{chemin}/{k}', 'propertyNames', m) for _, _, m in self.valider(k, s['propertyNames'], '')]
if isinstance(v, list):
if 'minItems' in s and len(v) < s['minItems']:
e.append((chemin or '/', 'minItems', f'{len(v)} élément(s) < {s["minItems"]}'))
if 'maxItems' in s and len(v) > s['maxItems']:
e.append((chemin or '/', 'maxItems', f'{len(v)} élément(s) > {s["maxItems"]}'))
if 'items' in s:
for i, x in enumerate(v):
e += self.valider(x, s['items'], f'{chemin}/{i}')
if est_nombre(v):
if 'minimum' in s and v < s['minimum']:
e.append((chemin or '/', 'minimum', f'{v} < {s["minimum"]}'))
if 'maximum' in s and v > s['maximum']:
e.append((chemin or '/', 'maximum', f'{v} > {s["maximum"]}'))
if 'exclusiveMinimum' in s and v <= s['exclusiveMinimum']:
e.append((chemin or '/', 'exclusiveMinimum', f'{v} ≤ {s["exclusiveMinimum"]}'))
if isinstance(v, str):
if 'minLength' in s and len(v) < s['minLength']:
e.append((chemin or '/', 'minLength', f'{len(v)} caractère(s) < {s["minLength"]}'))
if 'maxLength' in s and len(v) > s['maxLength']:
e.append((chemin or '/', 'maxLength', f'{len(v)} caractère(s) > {s["maxLength"]}'))
if 'pattern' in s and not self.motif(s['pattern']).search(v):
e.append((chemin or '/', 'pattern', f'{court(v)} ne suit pas {s["pattern"]}'))
for sous in s.get('allOf', []):
e += self.valider(v, sous, chemin)
if 'anyOf' in s:
essais = [self.valider(v, sous, chemin) for sous in s['anyOf']]
if all(essais):
e += [(c, k, m + ' (aucune branche de anyOf)') for c, k, m in min(essais, key=len)]
if 'oneOf' in s:
essais = [self.valider(v, sous, chemin) for sous in s['oneOf']]
ok = sum(1 for x in essais if not x)
if ok == 0:
# La branche dont le DISCRIMINANT (propriété à const/enum) correspond : c'est elle
# que l'émetteur visait, ses écarts sont ceux qui l'informent.
rang = [(self.discriminant_rate(v, sous), len(x)) for sous, x in zip(s['oneOf'], essais)]
meilleur = essais[rang.index(min(rang))]
e += [(c, k, m + ' (aucune branche de oneOf)') for c, k, m in meilleur]
elif ok > 1:
e.append((chemin or '/', 'oneOf', f'{ok} branches de oneOf valides, une seule attendue'))
return e
# --- Le contrat : méthodes, contrôles propres au protocole ----------------------------------------
class Contrat:
def __init__(self, doc):
self.doc = doc
self.v = Validateur(doc)
self.methodes = {m['name']: m for m in doc.get('methods', [])}
regles = (doc.get('x-etm-semantic-rules') or {}).get('rules', [])
noms = {r['name'] for r in regles}
manquantes = noms - set(REGLES_IMPLANTEES)
orphelines = set(REGLES_IMPLANTEES) - noms
if manquantes or orphelines:
raise ErreurSchema('x-etm-semantic-rules et cet outil divergent — '
f'sans implantation : {sorted(manquantes)} ; absentes du schéma : {sorted(orphelines)}')
self.regles = {r['name']: r for r in regles}
self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', [])
self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', ''))
self.cles_motif = doc.get('x-etm-reason-params') or {}
def methode(self, nom):
if nom not in self.methodes:
raise ErreurSchema(f'méthode « {nom} » absente du schéma')
return self.methodes[nom]
def schema_params(self, nom):
m = self.methode(nom)
if m.get('paramStructure', 'either') not in ('by-name', 'either'):
raise ErreurSchema(f'{nom} : seuls les params par nom sont gérés')
return {'type': 'object',
'properties': {p['name']: p['schema'] for p in m.get('params', [])},
'required': [p['name'] for p in m.get('params', []) if p.get('required')],
'additionalProperties': bool(m.get('x-etm-params-open', False))}
def valider_params(self, nom, params):
return self.v.valider(params, self.schema_params(nom))
def valider_result(self, nom, result):
m = self.methode(nom)
if 'result' not in m:
return [('/', 'result', f'{nom} est une notification : pas de résultat')]
e = self.v.valider(result, m['result']['schema'])
if nom == 'GetPlan' and isinstance(result, dict) and isinstance(result.get('slots'), list):
e += self.controler_motifs(result['slots'])
return e
def controler_motifs(self, slots):
"""x-etm-reason-params : seules les clés documentées du code."""
e = []
for i, s in enumerate(slots):
if not isinstance(s, dict) or not isinstance(s.get('actions'), list):
continue
for j, a in enumerate(s['actions']):
if not isinstance(a, dict) or not isinstance(a.get('reasonParams'), dict):
continue
admises = self.cles_motif.get(a.get('reasonCode'))
if admises is None:
continue # code hors liste : déjà un écart d'enum
for k in a['reasonParams']:
if k not in admises:
e.append((f'/slots/{i}/actions/{j}/reasonParams/{k}', 'x-etm-reason-params',
f'clé non admise pour {a.get("reasonCode")} (admises : {admises})'))
return e
# --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre -----------------------
REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered',
'malformed-action', 'unknown-load', 'unknown-kind', 'kind-mismatch',
'kind-not-announced', 'missing-reason', 'bad-value', 'duplicate-load', 'expired',
'no-current-slot', 'incomplete-slot',
'handshake-refused', 'invalid-handshake', 'major-mismatch']
INSTANT = re.compile(r'^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$')
def instant(v):
"""lireInstant() du moteur : chaîne ISO 8601 AVEC décalage, sinon None."""
if not isinstance(v, str) or not INSTANT.match(v):
return None
try:
return datetime.fromisoformat(v.replace('Z', '+00:00'))
except ValueError:
return None
def vrai_qvariant(v):
"""QVariant::toBool() — ce que lit le moteur pour `abstain`."""
if isinstance(v, bool):
return v
if est_nombre(v):
return v != 0
if isinstance(v, str):
return v.strip().lower() not in ('', '0', 'false')
return False
def assaini(v):
"""assainirTexte() : contrôles, séparateurs de ligne et formats → blanc, puis resserré."""
if not isinstance(v, str):
return ''
return ''.join(' ' if unicodedata.category(c) in ('Cc', 'Zl', 'Zp', 'Cf') else c for c in v).strip()
def kind_du_mecanisme(adapter):
if adapter in ('evcharger', 'relay-router', 'etmvariableload'):
return 'setpoint'
if adapter == 'sg-ready':
return 'state'
return ''
def nombre_moteur(v):
"""lireNombre() : un nombre JSON fini ; ni booléen, ni chaîne."""
return v if est_nombre(v) else None
def juger_action(a, charges, kinds, annonces):
if not isinstance(a, dict):
return 'malformed-action'
ident = a.get('loadId')
if not isinstance(ident, str) or not ident:
return 'malformed-action'
lc = charges.get(ident)
if lc is None:
return 'unknown-load'
kind = a.get('kind')
if not isinstance(kind, str) or kind not in kinds:
return 'unknown-kind'
attendu = kind_du_mecanisme(lc.get('adapter'))
if kind != attendu:
return 'kind-mismatch'
if annonces is not None and kind not in annonces:
return 'kind-not-announced'
if not assaini(a.get('reason')):
return 'missing-reason'
if attendu == 'state':
s = nombre_moteur(a.get('state'))
if s is None or not float(s).is_integer() or not 1 <= s <= 4:
return 'bad-value'
elif lc.get('adapter') == 'evcharger':
a_courant, a_puissance = 'currentA' in a, 'powerW' in a
c, p = nombre_moteur(a.get('currentA')), nombre_moteur(a.get('powerW'))
ph = nombre_moteur(a.get('phases'))
ok = ((a_courant or a_puissance)
and (not a_courant or (c is not None and c >= 0))
and (not a_puissance or (p is not None and p >= 0))
and ('phases' not in a or ph in (1, 3)))
if not ok:
return 'bad-value'
else:
p = nombre_moteur(a.get('powerW'))
if p is None or p < 0:
return 'bad-value'
return None
def juger_plan(result, request, handshake, kinds):
"""OptimizerPlan::valider() : le premier code qui refuse, ou None si le plan est applicable."""
if vrai_qvariant(result.get('abstain')):
return 'abstain'
slots = result.get('slots')
if not isinstance(slots, list):
return 'malformed-plan'
if not slots:
return 'empty-plan'
charges = {}
for lc in (request or {}).get('loads') or []:
if isinstance(lc, dict) and isinstance(lc.get('id'), str):
charges[lc['id']] = lc
annonces = None
caps = (handshake or {}).get('capabilities')
if isinstance(caps, dict) and 'kinds' in caps:
annonces = [str(k) for k in caps['kinds']] if isinstance(caps['kinds'], list) else []
creneaux = []
for s in slots:
if not isinstance(s, dict):
return 'malformed-plan'
debut, fin = instant(s.get('from')), instant(s.get('to'))
if debut is None or fin is None or debut >= fin:
return 'bad-time'
if creneaux and debut < creneaux[-1][1]:
return 'slots-unordered'
actions = s.get('actions')
if actions is not None and not isinstance(actions, list):
return 'malformed-plan'
vues = set()
for a in actions or []:
code = juger_action(a, charges, kinds, annonces)
if code:
return code
if a['loadId'] in vues:
return 'duplicate-load'
vues.add(a['loadId'])
creneaux.append((debut, fin, vues))
maintenant = instant((request or {}).get('timestamp'))
if 'validUntil' in result and result['validUntil'] is not None:
vu = instant(result['validUntil'])
if vu is None:
return 'bad-time'
if maintenant is not None and vu <= maintenant:
return 'expired'
courant = None
for c in creneaux:
if maintenant is not None and c[0] <= maintenant < c[1]:
courant = c
if courant is None:
return 'no-current-slot'
for ident, lc in charges.items():
if (lc.get('telemetry') or {}).get('available', True) is not False and ident not in courant[2]:
return 'incomplete-slot'
return None
def juger_handshake(message, version_moteur):
if 'error' in message:
return 'handshake-refused'
r = message.get('result')
if not isinstance(r, dict) or not isinstance(r.get('protocolVersion'), str) or not r['protocolVersion']:
return 'invalid-handshake'
if 'capabilities' in r and not isinstance(r['capabilities'], dict):
return 'invalid-handshake'
if r['protocolVersion'].split('.')[0] != version_moteur.split('.')[0]:
return 'major-mismatch'
return None
# --- Lecture, comptes, sortie ---------------------------------------------------------------------
class Bilan:
def __init__(self, contrat, semantique):
self.c = contrat
self.semantique = semantique
self.comptes = {}
self.ecarts = []
self.verdicts = {}
self.refus = 0
def compter(self, cle):
self.comptes[cle] = self.comptes.get(cle, 0) + 1
def noter(self, ou, partie, ecarts):
self.compter(partie)
for chemin, mot, msg in ecarts:
self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
print(f'{ou} {partie} {chemin} : {msg} [{mot}]')
def verdict(self, ou, code):
if code is None:
code = 'valide'
self.verdicts[code] = self.verdicts.get(code, 0) + 1
regle = self.c.regles.get(code)
if regle and regle['issue'] in ('invalid', 'connection-refused'):
self.refus += 1
print(f'{ou} règle sémantique « {code} » ({regle["issue"]}, fallbackCode {regle["fallbackCode"]}) : '
f'{regle["description"]}')
elif regle:
print(f'{ou} verdict « {code} » ({regle["issue"]}) — réponse permise')
def code(self):
return 1 if self.ecarts or (self.semantique and self.refus) else 0
def resume(self):
print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts),
'verdicts': self.verdicts, 'refus': self.refus},
ensure_ascii=False, sort_keys=True))
def lire_json(chemin):
with open(chemin, encoding='utf-8') as f:
return json.load(f)
def lignes(chemin):
with open(chemin, encoding='utf-8') as f:
for n, ligne in enumerate(f, 1):
if ligne.strip():
try:
yield n, json.loads(ligne)
except ValueError as e:
yield n, ErreurLigne(str(e))
class ErreurLigne:
def __init__(self, msg):
self.msg = msg
def session(b, chemin, requetes_seules):
"""Un message JSON-RPC par ligne ; réponses rapprochées par id."""
attente = {} # id → (méthode, params)
handshake = None # dernier result de Handshake accepté
for n, m in lignes(chemin):
ou = f'ligne {n}'
if isinstance(m, ErreurLigne) or not isinstance(m, dict):
b.noter(ou, 'ligne', [('/', 'json', 'ligne illisible ou non objet')])
continue
if m.get('jsonrpc') != '2.0':
b.noter(ou, 'enveloppe', [('/jsonrpc', 'const', 'jsonrpc doit valoir "2.0"')])
if 'method' in m:
nom = m['method']
if nom not in b.c.methodes:
b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')])
continue
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
if m.get('id') is not None:
attente[m['id']] = (nom, m.get('params'))
continue
if requetes_seules:
continue
if m.get('id') not in attente:
b.noter(ou, 'enveloppe', [('/id', 'id', 'réponse sans requête correspondante')])
continue
nom, params = attente.pop(m['id'])
if nom == 'Handshake':
if 'result' in m:
b.noter(ou, 'Handshake.result', b.c.valider_result('Handshake', m['result']))
code = juger_handshake(m, b.c.version)
if b.semantique:
b.verdict(ou, code)
if code is None:
handshake = m['result']
elif 'error' in m:
b.compter(f'{nom}.error')
print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)')
if b.semantique:
b.refus += 1
else:
b.noter(ou, f'{nom}.result', b.c.valider_result(nom, m.get('result')))
if b.semantique and nom == 'GetPlan' and isinstance(m.get('result'), dict):
b.verdict(ou, juger_plan(m['result'], params, handshake, b.c.kinds))
def trace(b, chemin):
"""Traces de décision (annexe D)."""
for n, t in lignes(chemin):
ou = f'ligne {n}'
if isinstance(t, ErreurLigne) or not isinstance(t, dict):
b.noter(ou, 'ligne', [('/', 'json', 'ligne illisible ou non objet')])
continue
if 'request' not in t:
b.compter('sans-requête') # L2 : aucune requête, rien à juger
continue
b.noter(ou, 'GetPlan.params', b.c.valider_params('GetPlan', t['request']))
reponse = t.get('response') or {}
if 'latencyMs' in t and isinstance(reponse, dict) and 'result' in reponse:
b.noter(ou, 'GetPlan.result', b.c.valider_result('GetPlan', reponse['result']))
if b.semantique and isinstance(reponse['result'], dict):
b.verdict(ou, juger_plan(reponse['result'], t['request'], None, b.c.kinds))
def main(argv=None):
p = argparse.ArgumentParser(prog='etm-powersync-schema-check',
description='Valide des messages du protocole optimiseur contre son contrat OpenRPC.')
p.add_argument('--schema', default=SCHEMA_DEFAUT, help='document OpenRPC (défaut : celui du dépôt)')
p.add_argument('--method', help='méthode du message (--params / --result)')
p.add_argument('--params', help='fichier JSON : les params d\'une requête')
p.add_argument('--result', help='fichier JSON : le result d\'une réponse')
p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)')
p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)')
p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne')
p.add_argument('--trace', help='traces de décision du moteur (annexe D)')
p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules')
p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes')
p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort')
a = p.parse_args(argv)
if a.subset:
print('mots-clés : ' + ' '.join(sorted(GERES)))
print('annotations : ' + ' '.join(sorted(ANNOTATIONS)) + ' x-…')
return 0
try:
contrat = Contrat(lire_json(a.schema))
b = Bilan(contrat, a.semantic)
if a.ndjson:
session(b, a.ndjson, a.requests_only)
elif a.trace:
trace(b, a.trace)
elif a.method and (a.params or a.result):
if a.params:
b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params)))
if a.result:
r = lire_json(a.result)
b.noter(a.result, f'{a.method}.result', contrat.valider_result(a.method, r))
if a.semantic and a.method == 'GetPlan' and isinstance(r, dict):
req = lire_json(a.request) if a.request else None
hs = lire_json(a.handshake) if a.handshake else None
if req is None:
raise ErreurSchema('--semantic sur GetPlan exige --request (charges, instant)')
b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds))
elif a.semantic and a.method == 'Handshake':
b.verdict(a.result, juger_handshake({'result': r}, contrat.version))
else:
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result')
except ErreurSchema as e:
print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr)
return 2
except (OSError, ValueError) as e:
print(f'ERREUR : {e}', file=sys.stderr)
return 2
b.resume()
return b.code()
if __name__ == '__main__':
sys.exit(main())