#!/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 `` et ``, le texte est celui que produit cet outil, et rien d'autre ; tout le reste (philosophie, transport, §7, annexes) est écrit à la main. `` : - `table:.params` — les params d'une méthode ; - `table:.result` — son résultat ; - `table:` — 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 `` 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= 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'^$') FIN = '' 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'') 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())