Validateur du contrat : juge error.data selon la version de l'optimiseur (protocole 1.3)
tools/etm-powersync-schema-check : - lit methods[].errors[].x-etm-data-schema : error.data d'une réponse error de ce code est jugé contre ce schéma (audité comme les autres) ; exigé à partir de x-etm-data-required-from, la version étant celle que l'optimiseur annonce dans SON Handshake ; en deçà, écart écrit en AVERTISSEMENT, sans effet sur le code de sortie ; version inconnue = celle du contrat ; - truncated n'accompagne que 50 problèmes exactement ; - aucune valeur reçue recopiée dans error.data : écart dès 1.2 (règle de 1.2). Le contrôle ne porte que sur les valeurs qu'un chemin conforme ne peut pas contenir — il ne peut pas accuser un chemin conforme (domain « battery » / params.battery) ; - --error (un objet error, avec --request et --handshake) et --responses-only (session de sonde, requêtes fautives à dessein) ; RÉSUMÉ compte les avertissements. Tests : forme exigée en 1.3 et avertie en 1.2 sur de vraies sessions de l'optimiseur de référence (normal, old-data, echo-value × 1.3, 1.2), forme ligne à ligne, recopie sans faux positif, contre-épreuve « sans x-etm-data-schema, rien n'est jugé ». Les cinq nouveaux tests échouent avec le validateur précédent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8c7c33be39
commit
d961b73ed7
@ -13,7 +13,9 @@ Trois choses :
|
|||||||
« bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle
|
« bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle
|
||||||
sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ;
|
sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ;
|
||||||
3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un
|
3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un
|
||||||
champ ajouté, un mauvais type le font échouer.
|
champ ajouté, un mauvais type le font échouer ;
|
||||||
|
4. `error.data` du -32602 de GetPlan (protocole 1.3) : forme exigée d'un optimiseur 1.3, avertie
|
||||||
|
d'un optimiseur 1.2, recopie d'une valeur reçue refusée des deux.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
@ -59,8 +61,16 @@ TABLE_MODES = {
|
|||||||
'unknown-load': ('règle', 'unknown-load'),
|
'unknown-load': ('règle', 'unknown-load'),
|
||||||
'forbidden-kind': ('règle', 'kind-mismatch'),
|
'forbidden-kind': ('règle', 'kind-mismatch'),
|
||||||
'missing-load': ('règle', 'incomplete-slot'),
|
'missing-load': ('règle', 'incomplete-slot'),
|
||||||
|
# Modes 1.3 : sur un contexte VALIDE et le bon token, ils se comportent comme `normal`.
|
||||||
|
'old-data': ('—', 'valide'),
|
||||||
|
'echo-value': ('—', 'valide'),
|
||||||
|
'major-first': ('—', 'valide'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans
|
||||||
|
# error.data, c'est une recopie.
|
||||||
|
MARQUEUR = 'valeur-sonde:7Qz9'
|
||||||
|
|
||||||
|
|
||||||
def verifier(*args):
|
def verifier(*args):
|
||||||
p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60)
|
p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60)
|
||||||
@ -117,6 +127,33 @@ def session(mode, dossier):
|
|||||||
return capture
|
return capture
|
||||||
|
|
||||||
|
|
||||||
|
def session_contexte_invalide(dossier, mode, annonce, params):
|
||||||
|
"""Handshake accepté puis UN GetPlan de contexte invalide ; rend le fichier capturé."""
|
||||||
|
sock_path = os.path.join(dossier, 'opt.sock')
|
||||||
|
capture = os.path.join(dossier, f'invalide-{mode}-{annonce}.ndjson')
|
||||||
|
proc = subprocess.Popen([sys.executable, '-I', '-B', REF, '--listen', f'unix://{sock_path}', '--mode', mode,
|
||||||
|
'--announce', annonce, '--capture', capture],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
||||||
|
try:
|
||||||
|
assert proc.stdout.readline().startswith('ÉCOUTE'), 'optimiseur de référence muet'
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.connect(sock_path)
|
||||||
|
tampon = [b'']
|
||||||
|
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake',
|
||||||
|
'params': {'protocolVersion': VERSION_MOTEUR,
|
||||||
|
'engine': 'etm-powersync-energy-plugin-etm/test'}}).encode() + b'\n')
|
||||||
|
assert lire_ligne(s, tampon)['result']['protocolVersion'] == annonce
|
||||||
|
s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 2, 'method': 'GetPlan', 'params': params}).encode() + b'\n')
|
||||||
|
reponse = lire_ligne(s, tampon)
|
||||||
|
assert reponse['error']['code'] == -32602, reponse
|
||||||
|
s.close()
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(5)
|
||||||
|
proc.stdout.close()
|
||||||
|
return capture
|
||||||
|
|
||||||
|
|
||||||
class TestCeQueLeMoteurEmet(unittest.TestCase):
|
class TestCeQueLeMoteurEmet(unittest.TestCase):
|
||||||
def test_les_references_du_serialiseur_sont_conformes(self):
|
def test_les_references_du_serialiseur_sont_conformes(self):
|
||||||
self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables')
|
self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables')
|
||||||
@ -239,5 +276,135 @@ class TestLeValidateurNeSeTaitPas(unittest.TestCase):
|
|||||||
self.assertNotIn('uniqueItems', sortie)
|
self.assertNotIn('uniqueItems', sortie)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDonneesDErreur(unittest.TestCase):
|
||||||
|
"""error.data du -32602 de GetPlan (protocole 1.3), sur de vraies sessions de l'optimiseur de
|
||||||
|
référence. La session porte un GetPlan de contexte invalide : le validateur la juge SANS
|
||||||
|
--semantic (sinon la réponse error est un refus, quel que soit son data)."""
|
||||||
|
|
||||||
|
# (mode, version annoncée) → (code de sortie, écarts > 0, avertissements > 0)
|
||||||
|
ATTENDU = {
|
||||||
|
('normal', '1.3'): (0, False, False),
|
||||||
|
('normal', '1.2'): (0, False, False),
|
||||||
|
('old-data', '1.3'): (1, True, False), # forme d'avant 1.3, annoncée 1.3 : refusée
|
||||||
|
('old-data', '1.2'): (0, False, True), # la même, annoncée 1.2 : avertie
|
||||||
|
('echo-value', '1.3'): (1, True, False),
|
||||||
|
('echo-value', '1.2'): (1, True, True), # forme avertie, RECOPIE refusée (règle de 1.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_forme_exigee_en_1_3_avertie_en_1_2_recopie_refusee(self):
|
||||||
|
params = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
|
||||||
|
params['timestamp'] = MARQUEUR # sans décalage, illisible : `format`
|
||||||
|
obtenu = {}
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
for (mode, annonce) in self.ATTENDU:
|
||||||
|
capture = session_contexte_invalide(d, mode, annonce, params)
|
||||||
|
code, r, sortie = verifier('--ndjson', capture, '--responses-only')
|
||||||
|
self.assertEqual(r['comptes'].get('GetPlan.error'), 1, sortie)
|
||||||
|
self.assertNotIn('GetPlan.params', r['comptes'])
|
||||||
|
self.assertEqual(r['comptes'].get('GetPlan.error.data'), 1, sortie)
|
||||||
|
obtenu[(mode, annonce)] = (code, r['ecarts'] > 0, r['avertissements'] > 0)
|
||||||
|
print(f' {mode:11s} {annonce} → code {code}, écarts {r["ecarts"]}, avertissements '
|
||||||
|
f'{r["avertissements"]}', file=sys.stderr)
|
||||||
|
if mode == 'echo-value':
|
||||||
|
self.assertIn('recopie la valeur reçue en params.timestamp', sortie)
|
||||||
|
self.assertNotIn(MARQUEUR, sortie, 'le validateur recopie lui-même la valeur')
|
||||||
|
if (mode, annonce) == ('old-data', '1.2'):
|
||||||
|
self.assertIn('AVERTISSEMENT', sortie)
|
||||||
|
self.assertIn('à aligner avant de s\'annoncer 1.3', sortie)
|
||||||
|
self.assertEqual(obtenu, self.ATTENDU)
|
||||||
|
|
||||||
|
def test_la_forme_vient_du_contrat(self):
|
||||||
|
# Contre-épreuve : sans x-etm-data-schema, la forme d'avant 1.3 annoncée 1.3 passe — c'est
|
||||||
|
# bien le contrat qui juge, pas une règle cachée dans l'outil.
|
||||||
|
with open(SCHEMA, encoding='utf-8') as f:
|
||||||
|
s = json.load(f)
|
||||||
|
for e in next(m for m in s['methods'] if m['name'] == 'GetPlan')['errors']:
|
||||||
|
e.pop('x-etm-data-schema', None)
|
||||||
|
e.pop('x-etm-data-required-from', None)
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
schema = os.path.join(d, 's.json')
|
||||||
|
with open(schema, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(s, f, ensure_ascii=False)
|
||||||
|
capture = session_contexte_invalide(d, 'old-data', '1.3', {'timestamp': MARQUEUR})
|
||||||
|
self.assertEqual(verifier('--ndjson', capture, '--responses-only')[0], 1)
|
||||||
|
code, r, sortie = verifier('--schema', schema, '--ndjson', capture, '--responses-only')
|
||||||
|
self.assertEqual(code, 0, sortie)
|
||||||
|
self.assertNotIn('GetPlan.error.data', r['comptes'])
|
||||||
|
|
||||||
|
def ecrire(self, d, nom, contenu):
|
||||||
|
chemin = os.path.join(d, nom)
|
||||||
|
with open(chemin, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(contenu, f, ensure_ascii=False)
|
||||||
|
return chemin
|
||||||
|
|
||||||
|
def juger(self, d, data, requete=None, version='1.3'):
|
||||||
|
args = ['--method', 'GetPlan', '--error',
|
||||||
|
self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide', 'data': data}),
|
||||||
|
'--handshake', self.ecrire(d, 'h.json', {'protocolVersion': version})]
|
||||||
|
if requete is not None:
|
||||||
|
args += ['--request', self.ecrire(d, 'q.json', requete)]
|
||||||
|
return verifier(*args)
|
||||||
|
|
||||||
|
def test_la_forme_ligne_a_ligne(self):
|
||||||
|
p = {'path': 'params.loads[0].id', 'problem': 'missing'}
|
||||||
|
cas = [
|
||||||
|
({'problems': [p]}, 0, None),
|
||||||
|
({'problems': [{'path': 'params', 'problem': 'type'}]}, 0, None),
|
||||||
|
({'problems': [{'path': 'params.loads[0].declared.estimatedPowerW.3', 'problem': 'range'}]}, 0, None),
|
||||||
|
({'problems': [p] * 50, 'truncated': True}, 0, None),
|
||||||
|
({'problems': []}, 1, '[minItems]'),
|
||||||
|
({'problems': [p] * 51}, 1, '[maxItems]'),
|
||||||
|
({'problems': [p] * 50, 'truncated': False}, 1, '[const]'),
|
||||||
|
({'problems': [p] * 3, 'truncated': True}, 1, '[x-etm-truncated]'),
|
||||||
|
({'problems': [dict(p, problem='absent')]}, 1, '[enum]'),
|
||||||
|
({'problems': [dict(p, path='/loads/0/id')]}, 1, '[pattern]'),
|
||||||
|
({'problems': [dict(p, path='loads[0].id')]}, 1, '[pattern]'),
|
||||||
|
({'problems': [dict(p, path='params.loads.0.id ')]}, 1, '[pattern]'),
|
||||||
|
({'problems': [dict(p, value=3)]}, 1, '[additionalProperties]'),
|
||||||
|
({'problems': [p], 'detail': 'x'}, 1, '[additionalProperties]'),
|
||||||
|
({'problems': [{'path': 'params.timestamp'}]}, 1, '[required]'),
|
||||||
|
({'problemes': ['params.timestamp : format']}, 1, '[required]'),
|
||||||
|
('contexte invalide', 1, '[type]'),
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
for data, code_attendu, mot in cas:
|
||||||
|
code, r, sortie = self.juger(d, data)
|
||||||
|
self.assertEqual(code, code_attendu, f'{data!r}\n{sortie}')
|
||||||
|
if mot:
|
||||||
|
self.assertIn(mot, sortie, data)
|
||||||
|
# data absent : écart en 1.3
|
||||||
|
chemin = self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide'})
|
||||||
|
code, _, sortie = verifier('--method', 'GetPlan', '--error', chemin)
|
||||||
|
self.assertEqual(code, 1, sortie)
|
||||||
|
self.assertIn('« data » manquant', sortie)
|
||||||
|
# Un autre code d'erreur n'a pas de forme normée : rien à juger.
|
||||||
|
chemin = self.ecrire(d, 'e.json', {'code': -32603, 'message': 'erreur interne', 'data': [1, 'x']})
|
||||||
|
code, r, sortie = verifier('--method', 'GetPlan', '--error', chemin)
|
||||||
|
self.assertEqual((code, r['ecarts']), (0, 0), sortie)
|
||||||
|
|
||||||
|
def test_la_recopie_ne_peut_pas_accuser_un_chemin_conforme(self):
|
||||||
|
# Valeurs de la requête qui sont AUSSI des noms de champ (domain « battery », trigger
|
||||||
|
# « forecast », meter.source « meter ») : un chemin conforme les contient, ce n'est pas une
|
||||||
|
# recopie. Une valeur à tiret ou à deux-points recopiée, elle, l'est.
|
||||||
|
requete = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8')))
|
||||||
|
requete['trigger'] = 'forecast'
|
||||||
|
requete['loads'][0]['domain'] = 'battery'
|
||||||
|
data = {'problems': [{'path': 'params.battery.socPercent', 'problem': 'range'},
|
||||||
|
{'path': 'params.forecast.pv[3].from', 'problem': 'format'},
|
||||||
|
{'path': 'params.meter.source', 'problem': 'enum'}]}
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
code, r, sortie = self.juger(d, data, requete)
|
||||||
|
self.assertEqual(code, 0, sortie)
|
||||||
|
ident = requete['loads'][0]['id']
|
||||||
|
self.assertRegex(ident, '[^A-Za-z0-9_]')
|
||||||
|
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident} : priorité hors bornes']}, requete, '1.2')
|
||||||
|
self.assertEqual(code, 1, sortie)
|
||||||
|
self.assertIn('recopie la valeur reçue en params.loads[0].id', sortie)
|
||||||
|
self.assertNotIn(ident, sortie.split('RÉSUMÉ')[0])
|
||||||
|
# Avant 1.2, la règle n'existait pas : avertie seulement.
|
||||||
|
code, r, sortie = self.juger(d, {'problemes': [f'charge {ident}']}, requete, '1.1')
|
||||||
|
self.assertEqual((code, r['ecarts'], r['avertissements'] > 0), (0, 0, True), sortie)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@ -60,6 +60,9 @@ Python 3, bibliothèque standard seule, aucun accès réseau. Le contrat :
|
|||||||
./tools/etm-powersync-schema-check --method GetPlan --params requete.json
|
./tools/etm-powersync-schema-check --method GetPlan --params requete.json
|
||||||
./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic
|
./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic
|
||||||
./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic
|
./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic
|
||||||
|
./tools/etm-powersync-schema-check --ndjson sonde.ndjson --responses-only # requêtes fautives à dessein
|
||||||
|
./tools/etm-powersync-schema-check --method GetPlan --error erreur.json --request requete.json \
|
||||||
|
--handshake handshake.json # error.data d'un -32602 (protocole 1.3)
|
||||||
./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic
|
./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic
|
||||||
./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré
|
./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré
|
||||||
|
|
||||||
@ -77,6 +80,10 @@ ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json <build>/tests/auto/protocolschema/etm-
|
|||||||
mot-clé est une erreur de schéma (code 2), jamais ignoré.
|
mot-clé est une erreur de schéma (code 2), jamais ignoré.
|
||||||
- **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de
|
- **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de
|
||||||
la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`).
|
la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`).
|
||||||
|
- **`error.data`** (protocole 1.3) : la forme d'`error.data` d'une erreur, quand le contrat la norme
|
||||||
|
(`x-etm-data-schema`, `-32602` de `GetPlan`), est exigée d'un optimiseur qui s'annonce
|
||||||
|
`x-etm-data-required-from` ou plus, et seulement AVERTIE en deçà (ligne `AVERTISSEMENT`, sans effet
|
||||||
|
sur le code de sortie) ; une valeur reçue recopiée dans `error.data` est un écart dès 1.2.
|
||||||
- **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de
|
- **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de
|
||||||
schéma.
|
schéma.
|
||||||
- Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite
|
- Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite
|
||||||
|
|||||||
@ -6,7 +6,10 @@
|
|||||||
etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json
|
etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json
|
||||||
etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json
|
etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json
|
||||||
[--request PARAMS.json] [--handshake RESULT.json] [--semantic]
|
[--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] --method M --error FICHIER.json
|
||||||
|
[--request PARAMS.json] [--handshake RESULT.json]
|
||||||
|
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic]
|
||||||
|
[--requests-only | --responses-only]
|
||||||
etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic]
|
etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic]
|
||||||
etm-powersync-schema-check --subset
|
etm-powersync-schema-check --subset
|
||||||
|
|
||||||
@ -28,18 +31,35 @@ Ce que l'outil lit en plus du JSON Schema :
|
|||||||
- `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ;
|
- `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
|
- `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
|
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.
|
l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence ;
|
||||||
|
- `methods[].errors[].x-etm-data-schema` (protocole 1.3) : la forme de `error.data` d'une réponse
|
||||||
|
error de ce code — `InvalidContextData` pour le `-32602` de `GetPlan`. Exigée d'un optimiseur qui
|
||||||
|
s'annonce `x-etm-data-required-from` ou plus (version lue dans SON `Handshake`) ; plus ancienne,
|
||||||
|
un écart n'est qu'un AVERTISSEMENT, qui ne change pas le code de sortie. Une version inconnue (pas
|
||||||
|
de handshake) est jugée comme celle du contrat. En plus de la forme, deux contrôles :
|
||||||
|
`truncated` n'accompagne que 50 problèmes exactement ; et AUCUNE VALEUR REÇUE n'est recopiée dans
|
||||||
|
`error.data` (§7 — règle de 1.2, donc un écart dès 1.2, jamais un avertissement). Ce dernier
|
||||||
|
contrôle porte sur les valeurs textuelles de la requête qu'un chemin conforme ne peut pas
|
||||||
|
contenir (4 caractères au moins, dont un hors `[A-Za-z0-9_]` : instants, identifiants à tiret,
|
||||||
|
libellés) : il ne peut pas accuser un chemin conforme. Une recopie de nombre ou de mot nu lui
|
||||||
|
échappe ; la forme 1.3, fermée, la rend inexprimable.
|
||||||
|
|
||||||
Modes :
|
Modes :
|
||||||
- `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est
|
- `--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
|
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).
|
`Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes) ;
|
||||||
|
`--responses-only` que ce qu'émet l'optimiseur (réponses) — pour une session de SONDE, dont les
|
||||||
|
requêtes sont fautives à dessein (un contexte invalide, pour juger le `-32602` qui lui répond).
|
||||||
- `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de
|
- `--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
|
`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.
|
(`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. Une
|
||||||
|
erreur n'y porte que le code d'échec du moteur : rien à juger de son `data`.
|
||||||
|
- `--error` : l'objet `error` d'une réponse à `--method` ; son `data` est jugé comme en session,
|
||||||
|
la version tirée de `--handshake` (le `result` du Handshake), la requête de `--request`.
|
||||||
|
|
||||||
Sortie : une ligne par écart, puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
|
Sortie : une ligne par écart, une ligne `AVERTISSEMENT …` par écart toléré (forme normée après la
|
||||||
verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un
|
version de l'optimiseur), puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
|
||||||
|
avertissements, 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.
|
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.
|
Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises.
|
||||||
"""
|
"""
|
||||||
@ -125,6 +145,10 @@ class Validateur:
|
|||||||
racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema')))
|
racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema')))
|
||||||
if 'result' in m:
|
if 'result' in m:
|
||||||
racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema')))
|
racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema')))
|
||||||
|
for e in m.get('errors', []):
|
||||||
|
if 'x-etm-data-schema' in e:
|
||||||
|
racines.append((f'méthode {m.get("name")}, erreur {e.get("code")}, data',
|
||||||
|
e['x-etm-data-schema']))
|
||||||
for ou, s in racines:
|
for ou, s in racines:
|
||||||
self.auditer_schema(s, ou)
|
self.auditer_schema(s, ou)
|
||||||
|
|
||||||
@ -284,6 +308,16 @@ class Contrat:
|
|||||||
self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', [])
|
self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', [])
|
||||||
self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', ''))
|
self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', ''))
|
||||||
self.cles_motif = doc.get('x-etm-reason-params') or {}
|
self.cles_motif = doc.get('x-etm-reason-params') or {}
|
||||||
|
# (méthode, code) → (schéma de error.data, version de l'optimiseur à partir de laquelle il est exigé)
|
||||||
|
self.donnees_erreur = {}
|
||||||
|
for m in doc.get('methods', []):
|
||||||
|
for e in m.get('errors', []):
|
||||||
|
if 'x-etm-data-schema' in e:
|
||||||
|
depuis = version_lue(e.get('x-etm-data-required-from'))
|
||||||
|
if depuis is None:
|
||||||
|
raise ErreurSchema(f'{m["name"]}, erreur {e.get("code")} : x-etm-data-required-from '
|
||||||
|
f'absent ou illisible')
|
||||||
|
self.donnees_erreur[(m['name'], e.get('code'))] = (e['x-etm-data-schema'], depuis)
|
||||||
|
|
||||||
def methode(self, nom):
|
def methode(self, nom):
|
||||||
if nom not in self.methodes:
|
if nom not in self.methodes:
|
||||||
@ -330,6 +364,85 @@ class Contrat:
|
|||||||
return e
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
# --- error.data (protocole 1.3) ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def version_lue(v):
|
||||||
|
"""« 1.3 » → (1, 3) ; « 1 » → (1, 0) ; illisible → None."""
|
||||||
|
if not isinstance(v, str):
|
||||||
|
return None
|
||||||
|
morceaux = v.split('.')
|
||||||
|
try:
|
||||||
|
return int(morceaux[0]), int(morceaux[1]) if len(morceaux) > 1 else 0
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
TEXTE_HORS_CHEMIN = re.compile(r'[^A-Za-z0-9_]')
|
||||||
|
RECOPIE_DEPUIS = (1, 2) # « jamais la valeur reçue » : règle de 1.2 (§7)
|
||||||
|
MAX_PROBLEMES = 50
|
||||||
|
|
||||||
|
|
||||||
|
def valeurs_reconnaissables(params, chemin='params'):
|
||||||
|
"""Les valeurs textuelles de la requête qu'un chemin conforme ne peut pas contenir : (chemin, valeur)."""
|
||||||
|
if isinstance(params, dict):
|
||||||
|
for k, v in params.items():
|
||||||
|
yield from valeurs_reconnaissables(v, f'{chemin}.{k}')
|
||||||
|
elif isinstance(params, list):
|
||||||
|
for i, v in enumerate(params):
|
||||||
|
yield from valeurs_reconnaissables(v, f'{chemin}[{i}]')
|
||||||
|
elif isinstance(params, str) and len(params) >= 4 and TEXTE_HORS_CHEMIN.search(params):
|
||||||
|
yield chemin, params
|
||||||
|
|
||||||
|
|
||||||
|
def textes(v):
|
||||||
|
"""Toutes les chaînes d'un JSON, clés comprises."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
yield v
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
for k, x in v.items():
|
||||||
|
yield k
|
||||||
|
yield from textes(x)
|
||||||
|
elif isinstance(v, list):
|
||||||
|
for x in v:
|
||||||
|
yield from textes(x)
|
||||||
|
|
||||||
|
|
||||||
|
def juger_donnees_erreur(contrat, methode, erreur, requete, version):
|
||||||
|
"""error.data d'une réponse error : (écarts, avertissements), chacun (chemin, mot-clé, message).
|
||||||
|
|
||||||
|
`version` : protocolVersion annoncée par l'optimiseur au handshake (None : inconnue, jugée comme
|
||||||
|
celle du contrat). La forme n'est exigée qu'à partir de x-etm-data-required-from ; la recopie
|
||||||
|
d'une valeur reçue est un écart dès 1.2."""
|
||||||
|
if not isinstance(erreur, dict):
|
||||||
|
return [], []
|
||||||
|
forme = contrat.donnees_erreur.get((methode, erreur.get('code')))
|
||||||
|
if forme is None:
|
||||||
|
return [], []
|
||||||
|
schema, depuis = forme
|
||||||
|
v = version_lue(version) or version_lue(contrat.version)
|
||||||
|
if 'data' not in erreur:
|
||||||
|
ecarts_forme = [('/error', 'required', '« data » manquant')]
|
||||||
|
else:
|
||||||
|
data = erreur['data']
|
||||||
|
ecarts_forme = contrat.v.valider(data, schema, '/error/data')
|
||||||
|
if isinstance(data, dict) and data.get('truncated') is True and isinstance(data.get('problems'), list) \
|
||||||
|
and len(data['problems']) != MAX_PROBLEMES:
|
||||||
|
ecarts_forme.append(('/error/data/truncated', 'x-etm-truncated',
|
||||||
|
f'truncated avec {len(data["problems"])} problème(s) : seulement quand '
|
||||||
|
f'{MAX_PROBLEMES} sont donnés et d\'autres omis'))
|
||||||
|
recopies = []
|
||||||
|
if 'data' in erreur and isinstance(requete, dict):
|
||||||
|
vus = list(textes(erreur['data']))
|
||||||
|
for chemin, valeur in valeurs_reconnaissables(requete):
|
||||||
|
if any(valeur in t for t in vus):
|
||||||
|
recopies.append(('/error/data', 'x-etm-no-echo',
|
||||||
|
f'recopie la valeur reçue en {chemin} — jamais la valeur reçue (§7)'))
|
||||||
|
ecarts, avertissements = [], []
|
||||||
|
(ecarts if v >= depuis else avertissements).extend(ecarts_forme)
|
||||||
|
(ecarts if v >= RECOPIE_DEPUIS else avertissements).extend(recopies)
|
||||||
|
return ecarts, avertissements
|
||||||
|
|
||||||
|
|
||||||
# --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre -----------------------
|
# --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre -----------------------
|
||||||
|
|
||||||
REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered',
|
REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered',
|
||||||
@ -500,6 +613,7 @@ class Bilan:
|
|||||||
self.semantique = semantique
|
self.semantique = semantique
|
||||||
self.comptes = {}
|
self.comptes = {}
|
||||||
self.ecarts = []
|
self.ecarts = []
|
||||||
|
self.avertissements = []
|
||||||
self.verdicts = {}
|
self.verdicts = {}
|
||||||
self.refus = 0
|
self.refus = 0
|
||||||
|
|
||||||
@ -512,6 +626,22 @@ class Bilan:
|
|||||||
self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
|
self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
|
||||||
print(f'{ou} {partie} {chemin} : {msg} [{mot}]')
|
print(f'{ou} {partie} {chemin} : {msg} [{mot}]')
|
||||||
|
|
||||||
|
def avertir(self, ou, partie, ecarts, pourquoi):
|
||||||
|
for chemin, mot, msg in ecarts:
|
||||||
|
self.avertissements.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
|
||||||
|
print(f'AVERTISSEMENT {ou} {partie} {chemin} : {msg} [{mot}] — {pourquoi}')
|
||||||
|
|
||||||
|
def juger_erreur(self, ou, methode, erreur, requete, version):
|
||||||
|
ecarts, avertissements = juger_donnees_erreur(self.c, methode, erreur, requete, version)
|
||||||
|
if (methode, erreur.get('code') if isinstance(erreur, dict) else None) not in self.c.donnees_erreur:
|
||||||
|
return
|
||||||
|
partie = f'{methode}.error.data'
|
||||||
|
self.noter(ou, partie, ecarts)
|
||||||
|
forme = self.c.donnees_erreur[(methode, erreur['code'])]
|
||||||
|
self.avertir(ou, partie, avertissements,
|
||||||
|
f'optimiseur en {str(version)[:16]}, forme exigée à partir de '
|
||||||
|
f'{forme[1][0]}.{forme[1][1]} : à aligner avant de s\'annoncer {forme[1][0]}.{forme[1][1]}')
|
||||||
|
|
||||||
def verdict(self, ou, code):
|
def verdict(self, ou, code):
|
||||||
if code is None:
|
if code is None:
|
||||||
code = 'valide'
|
code = 'valide'
|
||||||
@ -529,6 +659,7 @@ class Bilan:
|
|||||||
|
|
||||||
def resume(self):
|
def resume(self):
|
||||||
print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts),
|
print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts),
|
||||||
|
'avertissements': len(self.avertissements),
|
||||||
'verdicts': self.verdicts, 'refus': self.refus},
|
'verdicts': self.verdicts, 'refus': self.refus},
|
||||||
ensure_ascii=False, sort_keys=True))
|
ensure_ascii=False, sort_keys=True))
|
||||||
|
|
||||||
@ -553,10 +684,11 @@ class ErreurLigne:
|
|||||||
self.msg = msg
|
self.msg = msg
|
||||||
|
|
||||||
|
|
||||||
def session(b, chemin, requetes_seules):
|
def session(b, chemin, requetes_seules, reponses_seules=False):
|
||||||
"""Un message JSON-RPC par ligne ; réponses rapprochées par id."""
|
"""Un message JSON-RPC par ligne ; réponses rapprochées par id."""
|
||||||
attente = {} # id → (méthode, params)
|
attente = {} # id → (méthode, params)
|
||||||
handshake = None # dernier result de Handshake accepté
|
handshake = None # dernier result de Handshake accepté
|
||||||
|
version = None # protocolVersion de l'optimiseur, lue dans ce result (error.data, 1.3)
|
||||||
for n, m in lignes(chemin):
|
for n, m in lignes(chemin):
|
||||||
ou = f'ligne {n}'
|
ou = f'ligne {n}'
|
||||||
if isinstance(m, ErreurLigne) or not isinstance(m, dict):
|
if isinstance(m, ErreurLigne) or not isinstance(m, dict):
|
||||||
@ -569,7 +701,8 @@ def session(b, chemin, requetes_seules):
|
|||||||
if nom not in b.c.methodes:
|
if nom not in b.c.methodes:
|
||||||
b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')])
|
b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')])
|
||||||
continue
|
continue
|
||||||
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
|
if not reponses_seules:
|
||||||
|
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
|
||||||
if m.get('id') is not None:
|
if m.get('id') is not None:
|
||||||
attente[m['id']] = (nom, m.get('params'))
|
attente[m['id']] = (nom, m.get('params'))
|
||||||
continue
|
continue
|
||||||
@ -587,9 +720,11 @@ def session(b, chemin, requetes_seules):
|
|||||||
b.verdict(ou, code)
|
b.verdict(ou, code)
|
||||||
if code is None:
|
if code is None:
|
||||||
handshake = m['result']
|
handshake = m['result']
|
||||||
|
version = handshake.get('protocolVersion')
|
||||||
elif 'error' in m:
|
elif 'error' in m:
|
||||||
b.compter(f'{nom}.error')
|
b.compter(f'{nom}.error')
|
||||||
print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)')
|
print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)')
|
||||||
|
b.juger_erreur(ou, nom, m['error'], params, version)
|
||||||
if b.semantique:
|
if b.semantique:
|
||||||
b.refus += 1
|
b.refus += 1
|
||||||
else:
|
else:
|
||||||
@ -623,12 +758,15 @@ def main(argv=None):
|
|||||||
p.add_argument('--method', help='méthode du message (--params / --result)')
|
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('--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('--result', help='fichier JSON : le result d\'une réponse')
|
||||||
|
p.add_argument('--error', help='fichier JSON : l\'objet error d\'une réponse (forme de son data)')
|
||||||
p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)')
|
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('--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('--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('--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('--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('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes')
|
||||||
|
p.add_argument('--responses-only', action='store_true',
|
||||||
|
help='--ndjson : ne juge que les réponses (session de sonde, requêtes fautives à dessein)')
|
||||||
p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort')
|
p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort')
|
||||||
a = p.parse_args(argv)
|
a = p.parse_args(argv)
|
||||||
if a.subset:
|
if a.subset:
|
||||||
@ -639,10 +777,12 @@ def main(argv=None):
|
|||||||
contrat = Contrat(lire_json(a.schema))
|
contrat = Contrat(lire_json(a.schema))
|
||||||
b = Bilan(contrat, a.semantic)
|
b = Bilan(contrat, a.semantic)
|
||||||
if a.ndjson:
|
if a.ndjson:
|
||||||
session(b, a.ndjson, a.requests_only)
|
if a.requests_only and a.responses_only:
|
||||||
|
p.error('--requests-only et --responses-only s\'excluent')
|
||||||
|
session(b, a.ndjson, a.requests_only, a.responses_only)
|
||||||
elif a.trace:
|
elif a.trace:
|
||||||
trace(b, a.trace)
|
trace(b, a.trace)
|
||||||
elif a.method and (a.params or a.result):
|
elif a.method and (a.params or a.result or a.error):
|
||||||
if a.params:
|
if a.params:
|
||||||
b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params)))
|
b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params)))
|
||||||
if a.result:
|
if a.result:
|
||||||
@ -656,8 +796,14 @@ def main(argv=None):
|
|||||||
b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds))
|
b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds))
|
||||||
elif a.semantic and a.method == 'Handshake':
|
elif a.semantic and a.method == 'Handshake':
|
||||||
b.verdict(a.result, juger_handshake({'result': r}, contrat.version))
|
b.verdict(a.result, juger_handshake({'result': r}, contrat.version))
|
||||||
|
if a.error:
|
||||||
|
contrat.methode(a.method)
|
||||||
|
hs = lire_json(a.handshake) if a.handshake else None
|
||||||
|
b.compter(f'{a.method}.error')
|
||||||
|
b.juger_erreur(a.error, a.method, lire_json(a.error), lire_json(a.request) if a.request else None,
|
||||||
|
hs.get('protocolVersion') if isinstance(hs, dict) else None)
|
||||||
else:
|
else:
|
||||||
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result')
|
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result / --error')
|
||||||
except ErreurSchema as e:
|
except ErreurSchema as e:
|
||||||
print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr)
|
print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user