diff --git a/tests/tools/etm-powersync-gen-protocol-doc-test.py b/tests/tools/etm-powersync-gen-protocol-doc-test.py new file mode 100644 index 0000000..28b0b4b --- /dev/null +++ b/tests/tools/etm-powersync-gen-protocol-doc-test.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +"""Test de tools/etm-powersync-gen-protocol-doc (lot « contrat OpenRPC »). + + python3 -I tests/tools/etm-powersync-gen-protocol-doc-test.py + +Le dépôt est à jour ; une prose non régénérée, un bloc édité à la main, une liste du code changée +font échouer `--check` ; les balises cassées et un dump du code incomplet sont des erreurs. +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +ICI = os.path.dirname(os.path.abspath(__file__)) +RACINE = os.path.join(ICI, '..', '..') +OUTIL = os.path.join(RACINE, 'tools', 'etm-powersync-gen-protocol-doc') +SCHEMA = os.path.join(RACINE, 'docs', 'etm-powersync-optimizer-protocol.openrpc.json') +PROSE = os.path.join(RACINE, 'docs', 'OPTIMIZER_PROTOCOL.md') + + +def lancer(*args): + p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60) + return p.returncode, p.stdout + p.stderr + + +class TestGenProtocolDoc(unittest.TestCase): + def setUp(self): + self.d = tempfile.mkdtemp() + self.schema = os.path.join(self.d, 's.json') + self.prose = os.path.join(self.d, 'p.md') + shutil.copy(SCHEMA, self.schema) + shutil.copy(PROSE, self.prose) + + def tearDown(self): + shutil.rmtree(self.d) + + def args(self, *x): + return ('--schema', self.schema, '--prose', self.prose) + x + + def lire(self, f): + with open(f, encoding='utf-8') as h: + return h.read() + + def ecrire(self, f, texte): + with open(f, 'w', encoding='utf-8') as h: + h.write(texte) + + def test_le_depot_est_a_jour(self): + code, sortie = lancer('--check') + self.assertEqual(code, 0, sortie) + self.assertRegex(sortie, r'✓ contrat canonique, [0-9]+ bloc') + + def test_un_contrat_change_sans_regenerer_la_prose_echoue(self): + doc = json.loads(self.lire(self.schema)) + doc['components']['schemas']['Meter']['properties']['exportW']['description'] = 'changée' + self.ecrire(self.schema, json.dumps(doc, ensure_ascii=False, indent=2) + '\n') + code, sortie = lancer(*self.args('--check')) + self.assertEqual(code, 1, sortie) + self.assertIn('blocs générés pas à jour', sortie) + code, _ = lancer(*self.args()) + self.assertEqual(code, 0) + self.assertIn('| changée |', self.lire(self.prose)) + self.assertEqual(lancer(*self.args('--check'))[0], 0) + + def test_un_bloc_edite_a_la_main_echoue(self): + p = self.lire(self.prose).replace('| `timestamp` | string | oui |', '| `timestamp` | string | non |', 1) + self.ecrire(self.prose, p) + self.assertEqual(lancer(*self.args('--check'))[0], 1) + + def test_un_contrat_non_canonique_echoue(self): + self.ecrire(self.schema, json.dumps(json.loads(self.lire(self.schema)), ensure_ascii=False) + '\n') + code, sortie = lancer(*self.args('--check')) + self.assertEqual(code, 1, sortie) + self.assertIn('pas à jour (forme canonique', sortie) + + def test_une_liste_du_code_changee_se_regenere(self): + doc = json.loads(self.lire(self.schema)) + listes = {} + for it in doc['x-etm-generated']['items']: + noeud = doc + for m in it['pointer'][1:].split('/'): + noeud = noeud[m] + listes[it['from']] = noeud + listes['DecisionCode::optimizerEmittableCodes()'] = listes['DecisionCode::optimizerEmittableCodes()'] + ['NOUVEAU_CODE'] + dump = os.path.join(self.d, 'listes.json') + self.ecrire(dump, json.dumps(listes)) + code, sortie = lancer(*self.args('--enums-from', dump, '--check')) + self.assertEqual(code, 1, sortie) # le contrat commité n'a pas la liste du code + self.assertEqual(lancer(*self.args('--enums-from', dump))[0], 0) + self.assertIn('NOUVEAU_CODE', self.lire(self.schema)) + self.assertIn('`NOUVEAU_CODE`', self.lire(self.prose)) # la table des codes suit + del listes['TariffConfig::includes()'] + self.ecrire(dump, json.dumps(listes)) + code, sortie = lancer(*self.args('--enums-from', dump)) + self.assertEqual(code, 2, sortie) + listes['TariffConfig::includes()'] = ['energy'] + listes['Liste::orpheline()'] = [] + self.ecrire(dump, json.dumps(listes)) + code, sortie = lancer(*self.args('--enums-from', dump)) + self.assertEqual(code, 2, sortie) + self.assertIn('orpheline', sortie) + + def test_des_balises_cassees_sont_une_erreur(self): + p = self.lire(self.prose) + i = p.index('') + self.ecrire(self.prose, p[:i] + p[i + len(''):]) + self.assertEqual(lancer(*self.args('--check'))[0], 2) + self.ecrire(self.prose, p.replace('BEGIN GENERATED rules', 'BEGIN GENERATED inconnu', 1)) + code, sortie = lancer(*self.args('--check')) + self.assertEqual(code, 2, sortie) + self.ecrire(self.prose, 'aucune balise\n') + self.assertEqual(lancer(*self.args('--check'))[0], 2) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tools/ci-quality.sh b/tools/ci-quality.sh index 511e29d..c342578 100755 --- a/tools/ci-quality.sh +++ b/tools/ci-quality.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync # -# Contrôle de qualité documentaire — TROIS vérifications, un seul endroit. +# Contrôle de qualité documentaire — CINQ vérifications, un seul endroit. # # ./tools/ci-quality.sh # @@ -15,6 +15,11 @@ # qu'en ligne de tableau ; un domicile ambigu ; un numéro retiré réattribué. # 2. La DOC DOXYGEN est-elle complète ? (0 avertissement — DoD §5 d'AGENTS.md) # 3. Le tout est-il REPRODUCTIBLE ? (deux passes donnent le même index) +# 4. Un verdict de test peut-il dépendre de l'HEURE ? (tools/check-simulation-clock.py) +# 5. La prose du protocole est-elle GÉNÉRÉE depuis son contrat ? +# (tools/etm-powersync-gen-protocol-doc --check : tableaux de messages à jour, contrat +# OpenRPC sous sa forme canonique). Ici, l'artefact généré EST commité — la prose est +# lue par des humains et des optimiseurs tiers — donc on régénère et on refuse un écart. # # ── CE QU'ON NE VÉRIFIE PAS, ET POURQUOI ──────────────────────────────────── # @@ -58,7 +63,7 @@ ok() { printf '%s✓ %s%s\n' "$VERT" "$1" "$FIN"; } reparer() { printf ' → réparer : %s\n' "$1"; } # --- 1. L'index des règles est-il vrai ? ------------------------------------ -titre "1/4 · index des règles" +titre "1/5 · index des règles" if python3 tools/gen-rules.py; then ok "index complet — aucun orphelin, aucun domicile ambigu" else @@ -68,7 +73,7 @@ else fi # --- 2. La doc est-elle complète ? ------------------------------------------ -titre "2/4 · documentation Doxygen" +titre "2/5 · documentation Doxygen" if ! command -v doxygen >/dev/null 2>&1 || ! command -v dot >/dev/null 2>&1; then ko "doxygen ou graphviz absent — la vérification n'a PAS eu lieu" reparer "apt install doxygen graphviz" @@ -91,7 +96,7 @@ else fi # --- 3. Le générateur est-il déterministe ? --------------------------------- -titre "3/4 · reproductibilité de l'index" +titre "3/5 · reproductibilité de l'index" A="$(mktemp)"; B="$(mktemp)" python3 tools/gen-rules.py >/dev/null 2>&1; cp doc-generated/RULES.md "$A" python3 tools/gen-rules.py >/dev/null 2>&1; cp doc-generated/RULES.md "$B" @@ -105,7 +110,7 @@ fi rm -f "$A" "$B" # --- 4. Un verdict de test peut-il dépendre de l'HEURE ? --------------------- -titre "4/4 · horloge des déclencheurs" +titre "4/5 · horloge des déclencheurs" if sortie="$(python3 tools/check-simulation-clock.py 2>&1)"; then ok "${sortie#✓ }" else @@ -114,6 +119,16 @@ else reparer "entourer l'appel de #ifndef ENERGY_SIMULATION, comme ses voisins" fi +# --- 5. La prose du protocole est-elle générée depuis le contrat ? ----------- +titre "5/5 · protocole optimiseur : prose générée depuis le contrat OpenRPC" +if sortie="$(python3 -I tools/etm-powersync-gen-protocol-doc --check 2>&1)"; then + ok "${sortie#✓ }" +else + ko "docs/OPTIMIZER_PROTOCOL.md ou le contrat OpenRPC n'est pas à jour" + printf '\n'; printf '%s\n' "$sortie" | head -20 | sed 's/^/ /' + reparer "python3 tools/etm-powersync-gen-protocol-doc (puis relire le diff et commiter)" +fi + printf '\n' if [ "$echec" -eq 0 ]; then printf '%s✓ qualité documentaire : tout passe.%s\n' "$VERT" "$FIN" diff --git a/tools/etm-powersync-gen-protocol-doc b/tools/etm-powersync-gen-protocol-doc new file mode 100755 index 0000000..59e72c3 --- /dev/null +++ b/tools/etm-powersync-gen-protocol-doc @@ -0,0 +1,345 @@ +#!/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())