etm-powersync-energy-plugin.../tools/etm-powersync-gen-protocol-doc
Patrick Schurig ead662e044 feat(outils): etm-powersync-gen-protocol-doc, prose générée depuis le contrat
Tableaux de messages (champs, types, obligatoire, valeurs, sens), règles
sémantiques et codes de motif, entre balises BEGIN/END GENERATED de
OPTIMIZER_PROTOCOL.md. --check (code 1 si la prose ou la forme canonique du
contrat n'est pas à jour) branché en 5/5 de tools/ci-quality.sh. --enums-from
pose les listes fermées du contrat depuis le dump du code.

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

346 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
"""Génère les parties « messages » de docs/OPTIMIZER_PROTOCOL.md depuis le contrat OpenRPC.
etm-powersync-gen-protocol-doc réécrit les blocs générés de la prose
etm-powersync-gen-protocol-doc --check code 1 si la prose ou le contrat n'est pas à jour
etm-powersync-gen-protocol-doc --enums-from LISTES.json
réécrit les listes fermées du contrat depuis le
CODE (dump de la suite etm-powersync-protocolschema)
Bibliothèque standard seule, aucun accès réseau.
BLOCS GÉNÉRÉS. Entre `<!-- BEGIN GENERATED <quoi> -->` et `<!-- END GENERATED -->`, le texte est
celui que produit cet outil, et rien d'autre ; tout le reste (philosophie, transport, §7, annexes)
est écrit à la main. `<quoi>` :
- `table:<Méthode>.params` — les params d'une méthode ;
- `table:<Méthode>.result` — son résultat ;
- `table:<Composant>` — un schéma de `components.schemas` (un `oneOf` donne une table par branche) ;
- `rules` — `x-etm-semantic-rules` ; `reason-codes` — codes émissibles et leurs clés de paramètres.
Une table déroule les objets EN LIGNE (`declared.minPowerW`) ; un `$ref` renvoie à la table de son
composant. Un `<quoi>` inconnu, une balise sans fin, un bloc imbriqué : erreur (code 2).
`--check` vérifie aussi que le contrat est sous sa forme CANONIQUE (`json.dumps`, indentation 2,
UTF-8, clés dans leur ordre) : c'est la forme qu'écrit `--enums-from`, et une régénération ne
doit produire que le diff des listes.
LISTES FERMÉES. `x-etm-generated.items[]` donne, pour chaque liste, son pointeur JSON dans le
contrat et sa source dans le code (`from`). `--enums-from` lit le dictionnaire `from → valeur`
écrit par `ETM_POWERSYNC_CODE_LISTS=<fichier> etm-powersync-protocolschema
testClosedListsAreGeneratedFromTheCode`, et pose chaque valeur à son pointeur. Une source absente
du dictionnaire, ou une entrée du dictionnaire qu'aucun pointeur ne lit : erreur (code 2).
"""
import argparse
import difflib
import json
import os
import re
import sys
ICI = os.path.dirname(os.path.abspath(__file__))
SCHEMA = os.path.join(ICI, '..', 'docs', 'etm-powersync-optimizer-protocol.openrpc.json')
PROSE = os.path.join(ICI, '..', 'docs', 'OPTIMIZER_PROTOCOL.md')
DEBUT = re.compile(r'^<!-- BEGIN GENERATED (\S+) -->$')
FIN = '<!-- END GENERATED -->'
class Erreur(Exception):
pass
def canonique(doc):
return json.dumps(doc, ensure_ascii=False, indent=2) + '\n'
# --- Rendu d'un schéma ------------------------------------------------------------------------------
class Rendu:
def __init__(self, doc):
self.doc = doc
self.schemas = doc.get('components', {}).get('schemas', {})
def resoudre(self, s):
while isinstance(s, dict) and '$ref' in s:
nom = s['$ref'].rsplit('/', 1)[-1]
if nom not in self.schemas:
raise Erreur(f'$ref {s["$ref"]} introuvable')
s = self.schemas[nom]
return s
@staticmethod
def nom_ref(s):
return s['$ref'].rsplit('/', 1)[-1] if isinstance(s, dict) and '$ref' in s else None
def type_de(self, s):
"""Le type, en une cellule : `string`, `number \\| null`, `[Load]`, `Site`…"""
if s is True or s == {}:
return 'quelconque'
ref = self.nom_ref(s)
if ref:
cible = self.schemas[ref]
if 'type' in cible and cible.get('type') not in ('object', ['object']) and 'properties' not in cible:
return self.type_de(cible) # liste fermée : son type, ses valeurs dans « Valeurs »
return f'`{ref}`'
for cle in ('oneOf', 'anyOf'):
if cle in s:
return ' \\| '.join(self.type_de(b) for b in s[cle])
if 'const' in s:
return 'null' if s['const'] is None else type(s['const']).__name__.replace('str', 'string').replace('bool', 'boolean').replace('int', 'integer').replace('float', 'number')
t = s.get('type')
types = [t] if isinstance(t, str) else list(t or [])
out = []
for x in types:
if x == 'array':
out.append('[' + self.type_de(s.get('items', {})) + ']')
else:
out.append(x)
if not out and 'enum' in s:
out.append('enum')
return ' \\| '.join(out) if out else 'quelconque'
def valeurs(self, s):
"""Valeurs permises, en une cellule."""
s0 = s
ref = self.nom_ref(s)
if ref:
s = self.schemas[ref]
if s.get('type') == 'array' and isinstance(s.get('items'), dict):
return self.valeurs(s['items'])
morceaux = []
if 'const' in s:
morceaux.append(f'`{json.dumps(s["const"], ensure_ascii=False)}`')
if 'enum' in s:
morceaux.append(' · '.join(f'`{v}`' if isinstance(v, str) else f'`{json.dumps(v)}`' for v in s['enum']))
for cle, signe in (('minimum', '≥'), ('exclusiveMinimum', '>'), ('maximum', '≤')):
if cle in s:
morceaux.append(f'{signe} {s[cle]}')
if 'minLength' in s:
morceaux.append(f'≥ {s["minLength"]} car.')
if 'minItems' in s:
morceaux.append(f'≥ {s["minItems"]} élément(s)')
if 'pattern' in s and ref is None and 'Instant' not in str(s.get('description', '')):
morceaux.append(f'motif `{s["pattern"]}`')
if 'pattern' in s and 'Instant' in str(s.get('description', '')):
morceaux.append('instant avec décalage')
for cle in ('oneOf', 'anyOf'):
for b in s.get(cle, []):
if self.nom_ref(b) is None and b.get('type') != 'null' and b is not s0:
v = self.valeurs(b)
if v:
morceaux.append(v)
return ' ; '.join(m for m in morceaux if m)
@staticmethod
def sens(s):
d = s.get('description', '') if isinstance(s, dict) else ''
return d.replace('|', '\\|').replace('\n', ' ')
def lignes(self, props, requis, prefixe=''):
out = []
for nom, s in props.items():
chemin = f'{prefixe}{nom}'
r = self.resoudre(s)
sens = self.sens(s) or (self.sens(r) if self.nom_ref(s) and r.get('type') not in ('object',) else '')
out.append(f'| `{chemin}` | {self.type_de(s)} | {"oui" if nom in requis else "non"} | '
f'{self.valeurs(s)} | {sens} |')
# Un objet EN LIGNE se déroule ; un composant nommé a sa propre table.
if self.nom_ref(s) is None and isinstance(s, dict) and isinstance(s.get('properties'), dict):
out += self.lignes(s['properties'], s.get('required', []), chemin + '.')
return out
def table(self, props, requis, titre=None, description=None, ouvert=None):
out = []
if titre:
out.append(f'**{titre}**' + (f' — {description}' if description else ''))
out.append('')
out.append('| Champ | Type | Obligatoire | Valeurs | Sens |')
out.append('|---|---|---|---|---|')
out += self.lignes(props, requis)
if ouvert is not None:
out.append('')
out.append('Champs non déclarés : ' + ('**refusés** (forme émise par le moteur, fermée).' if ouvert is False
else 'ignorés (§9).'))
return out
def composant(self, nom):
if nom not in self.schemas:
raise Erreur(f'composant « {nom} » absent du contrat')
s = self.schemas[nom]
if 'oneOf' in s:
out = []
if s.get('description'):
out += [self.sens(s), '']
for i, b in enumerate(s['oneOf'], 1):
r = self.nom_ref(b)
out += (self.objet(r, self.schemas[r]) if r else self.objet(f'{nom}, forme {i}', b)) + ['']
return out[:-1]
return self.objet(nom, s)
def objet(self, nom, s):
if 'properties' not in s:
return [f'`{nom}` : {self.type_de(s)} — {self.valeurs(s)}. {self.sens(s)}'.rstrip()]
out = self.table(s['properties'], s.get('required', []), titre=f'`{nom}`',
description=self.sens(s) or None, ouvert=s.get('additionalProperties', True))
if 'anyOf' in s and all(set(b) == {'required'} for b in s['anyOf']):
out.append('Au moins un de : ' + ', '.join('`' + '`+`'.join(b['required']) + '`' for b in s['anyOf']) + '.')
return out
def methode(self, nom, partie):
ms = {m['name']: m for m in self.doc.get('methods', [])}
if nom not in ms:
raise Erreur(f'méthode « {nom} » absente du contrat')
m = ms[nom]
if partie == 'params':
props = {p['name']: dict(p['schema'], description=p.get('description', p['schema'].get('description', '')))
if isinstance(p['schema'], dict) and 'description' in p else p['schema'] for p in m['params']}
requis = [p['name'] for p in m['params'] if p.get('required')]
return self.table(props, requis, titre=f'`{nom}` — params', ouvert=m.get('x-etm-params-open', False))
if 'result' not in m:
return [f'`{nom}` est une notification : pas de résultat.']
s = m['result']['schema']
ref = self.nom_ref(s)
if ref:
return self.composant(ref)
return self.objet(f'{nom}.result', s)
def regles(self):
r = self.doc.get('x-etm-semantic-rules', {})
out = [self.sens(r), '', '| Règle | Méthode | Issue | `fallbackCode` | Échec compté | Il faut aussi | Le schéma la voit aussi | Ce qui est refusé |',
'|---|---|---|---|---|---|---|---|']
for x in r.get('rules', []):
out.append(f'| `{x["name"]}` | {x["method"]} | {x["issue"]} | `{x["fallbackCode"]}` | '
f'{"oui" if x.get("countsAsFailure") else "non"} | {", ".join(x.get("needs", [])) or "—"} | '
f'{"oui" if x.get("schemaAlsoCatches") else "non"} | {self.sens(x)} |')
return out
def codes(self):
cles = self.doc.get('x-etm-reason-params', {})
out = ['| `reasonCode` | Clés de `reasonParams` admises |', '|---|---|']
for code in self.schemas['ReasonCode']['enum']:
out.append(f'| `{code}` | {", ".join(f"`{k}`" for k in cles.get(code, [])) or "—"} |')
return out
def bloc(self, quoi):
if quoi == 'rules':
return self.regles()
if quoi == 'reason-codes':
return self.codes()
if quoi.startswith('table:'):
cible = quoi[len('table:'):]
if '.' in cible:
nom, partie = cible.split('.', 1)
if partie not in ('params', 'result'):
raise Erreur(f'partie « {partie} » inconnue (params | result)')
return self.methode(nom, partie)
return self.composant(cible)
raise Erreur(f'bloc « {quoi} » inconnu')
def regenerer(prose, rendu):
out, i, lignes, vus = [], 0, prose.split('\n'), 0
while i < len(lignes):
m = DEBUT.match(lignes[i])
if lignes[i] == FIN:
raise Erreur(f'ligne {i + 1} : {FIN} sans début')
if not m:
out.append(lignes[i])
i += 1
continue
j = i + 1
while j < len(lignes) and lignes[j] != FIN:
if DEBUT.match(lignes[j]):
raise Erreur(f'ligne {j + 1} : bloc imbriqué')
j += 1
if j == len(lignes):
raise Erreur(f'ligne {i + 1} : bloc « {m.group(1)} » sans {FIN}')
out.append(lignes[i])
out.append(f'<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par '
f'tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->')
out.append('')
out += rendu.bloc(m.group(1))
out.append('')
out.append(FIN)
vus += 1
i = j + 1
if vus == 0:
raise Erreur('aucun bloc généré dans la prose — balises perdues ?')
return '\n'.join(out), vus
def poser(doc, pointeur, valeur):
morceaux = pointeur[1:].split('/')
noeud = doc
for m in morceaux[:-1]:
if not isinstance(noeud, dict) or m not in noeud:
raise Erreur(f'pointeur {pointeur} : « {m} » absent')
noeud = noeud[m]
if not isinstance(noeud, dict) or morceaux[-1] not in noeud:
raise Erreur(f'pointeur {pointeur} : « {morceaux[-1]} » absent')
noeud[morceaux[-1]] = valeur
def depuis_le_code(doc, listes):
items = (doc.get('x-etm-generated') or {}).get('items', [])
lues = set()
for it in items:
if it['from'] not in listes:
raise Erreur(f'{it["from"]} absent du dump du code')
poser(doc, it['pointer'], listes[it['from']])
lues.add(it['from'])
orphelines = set(listes) - lues
if orphelines:
raise Erreur(f'listes du code qu\'aucun pointeur ne lit : {sorted(orphelines)}')
return len(items)
def main(argv=None):
p = argparse.ArgumentParser(prog='etm-powersync-gen-protocol-doc',
description='Génère les tableaux de messages de OPTIMIZER_PROTOCOL.md depuis le contrat OpenRPC.')
p.add_argument('--schema', default=SCHEMA)
p.add_argument('--prose', default=PROSE)
p.add_argument('--check', action='store_true', help='ne réécrit rien ; code 1 si quelque chose n\'est pas à jour')
p.add_argument('--enums-from', help='dump des listes du code (JSON) : réécrit les listes fermées du contrat')
a = p.parse_args(argv)
try:
with open(a.schema, encoding='utf-8') as f:
brut = f.read()
doc = json.loads(brut)
if a.enums_from:
with open(a.enums_from, encoding='utf-8') as f:
n = depuis_le_code(doc, json.load(f))
if not a.check:
with open(a.schema, 'w', encoding='utf-8') as f:
f.write(canonique(doc))
print(f'contrat : {n} liste(s) posée(s) depuis le code')
with open(a.prose, encoding='utf-8') as f:
prose = f.read()
nouvelle, blocs = regenerer(prose, Rendu(doc))
except (Erreur, OSError, ValueError, KeyError) as e:
print(f'ERREUR : {e}', file=sys.stderr)
return 2
if a.check:
a_jour = True
if canonique(doc) != brut:
a_jour = False
print(f'✗ {a.schema} : pas à jour (forme canonique ou listes du code) — '
f'lancer tools/etm-powersync-gen-protocol-doc' + (' --enums-from …' if a.enums_from else ''))
if nouvelle != prose:
a_jour = False
print(f'✗ {a.prose} : blocs générés pas à jour — lancer tools/etm-powersync-gen-protocol-doc')
sys.stdout.writelines(list(difflib.unified_diff(prose.splitlines(True), nouvelle.splitlines(True),
'commité', 'régénéré', n=1))[:60])
if a_jour:
print(f'✓ contrat canonique, {blocs} bloc(s) générés à jour')
return 0 if a_jour else 1
if nouvelle != prose:
with open(a.prose, 'w', encoding='utf-8') as f:
f.write(nouvelle)
print(f'{blocs} bloc(s) générés écrits dans {a.prose}')
return 0
if __name__ == '__main__':
sys.exit(main())