Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier, d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient presque entièrement aux chaînes coupées à la main que ruff recolle quand elles tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune valeur ne change, et les clés de traduction non plus. Vérifié : la suite unitaire reste verte après le passage, et le contrôle de syntaxe ne signale rien. --- EN --- This repository's formatter is ruff since it replaced black, which knows no target beyond py313; this pass applies its standard to the whole tree at once, so that no substantive commit has to carry style. The difference is almost entirely the hand-split strings ruff joins back when they fit on one line, and the multiple "with" it merges: no value changes, nor do the translation keys. Checked: the unit suite stays green after the pass, and the syntax check reports nothing. Assisted-by: Claude Opus 5
556 lines
22 KiB
Python
556 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
"""Écran de périmètre pour « Transform data », en TUI.
|
||
|
||
Deux décisions y sont prises, que les invites textuelles ne savent pas
|
||
poser :
|
||
|
||
- QUELLES LIGNES nomment les colonnes. Le moteur les mesure, mais la
|
||
mesure se trompe dans les deux sens et l'opérateur doit pouvoir la
|
||
contredire — y compris en désignant PLUSIEURS lignes, un export portant
|
||
souvent une ligne de catégorie au-dessus de la ligne de champs.
|
||
- QUELLES COLONNES rester intactes. Sur un classeur ordinaire, neuf
|
||
colonnes du même type, du même compte et sans bornes ne se départagent
|
||
par rien d'autre que des VALEURS D'EXEMPLE, et taper des indices à
|
||
l'aveugle dans une invite n'est pas une réponse.
|
||
|
||
- run_transform_form(ctx, run_app=True) : rend une spec, `None` si
|
||
l'opérateur annule, `{}` pour retomber sur les invites textuelles.
|
||
|
||
Le contrat a TROIS valeurs et non deux : rendre `None` là où l'appelant
|
||
attend `{}` supprimerait le repli textuel en silence.
|
||
|
||
`ctx` est de la donnée PURE, bâtie par `contexte_depuis_rapport()` à
|
||
partir du JSON de `--report` déjà en main : aucune entrée-sortie et aucun
|
||
sous-processus depuis l'affichage.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
try:
|
||
from script.todo.todo_i18n import t
|
||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||
|
||
def t(key: str) -> str:
|
||
return key
|
||
|
||
|
||
# Les colonnes des deux tableaux : clé i18n et largeur. Les en-têtes de
|
||
# `SIGNES` ne passent pas par la traduction — ce sont des signes, non des
|
||
# mots, et les faire passer par `t()` les inscrit dans tout audit de clé
|
||
# manquante.
|
||
#
|
||
# `None` laisse la colonne s'ajuster à son contenu, et ne va qu'en
|
||
# DERNIÈRE place : ce qui dépasse le volet y sort par la droite sans rien
|
||
# masquer. Une colonne libre au MILIEU pousse hors de l'écran celles qui
|
||
# la suivent — trois exemples de quarante caractères font cent vingt-six
|
||
# colonnes, et les mesures, qui sont la raison de montrer ces lignes,
|
||
# disparaissaient derrière elles.
|
||
SIGNES = ("", "#")
|
||
COLONNES_DU_PERIMETRE = (
|
||
("", 3),
|
||
("#", 4),
|
||
("column label", 22),
|
||
("column type", 9),
|
||
("filled", 8),
|
||
("distinct", 9),
|
||
("examples", None),
|
||
)
|
||
COLONNES_SONDEES = (
|
||
("", 3),
|
||
("row no", 6),
|
||
# `pleines` était calculé et montré à personne. C'est le seul signal
|
||
# qui sépare un titre d'une ligne de champs quand les mesures se
|
||
# taisent — une feuille sans en-tête retenu n'en a pas — et il les
|
||
# sépare par un nombre : 1 pour le titre, 0 pour la ligne blanche,
|
||
# la largeur pour les champs.
|
||
("filled", 7),
|
||
("first values", 28),
|
||
("measures", None),
|
||
)
|
||
|
||
# Le marqueur d'une colonne, à l'écran. Un caractère chacun : un glyphe
|
||
# double-largeur décalerait la colonne suivante.
|
||
MARQUE_EN_PORTEE = "[ ]"
|
||
MARQUE_INTACTE = "[x]"
|
||
MARQUE_PLANCHER = "[!]"
|
||
MARQUE_CORRIGEE = "*"
|
||
MARQUE_DU_LOT = "="
|
||
|
||
# La légende, ici et nulle part ailleurs. Elle était écrite dans
|
||
# `compose` et RECOPIÉE dans son test, qui éprouvait donc sa propre copie :
|
||
# une marque ajoutée sans être expliquée passait au vert.
|
||
TEXTE_LEGENDE = (
|
||
"[x] untouched · [!] floored · * corrected · = from the batch table"
|
||
)
|
||
|
||
|
||
def contexte_depuis_rapport(rapport, memoire=None):
|
||
"""Le contexte de l'écran, tiré du rapport du moteur.
|
||
|
||
Pure transformation de données : c'est ce qui rend l'écran testable
|
||
sans fichier, et ce qui garantit qu'il n'ouvre rien lui-même.
|
||
|
||
`memoire` porte ce qu'une table de lot se rappelle — les lignes
|
||
d'en-tête qu'un fichier PRÉCÉDENT a fait corriger. Elle arrive
|
||
PRÉ-COCHÉE et marquée, jamais appliquée en silence : une réponse
|
||
fausse appliquée sans être vue est exactement comment une erreur
|
||
gagne tout un lot.
|
||
"""
|
||
memoire = memoire or {}
|
||
return {
|
||
"fichier": rapport.get("chemin", ""),
|
||
"format": rapport.get("format", ""),
|
||
"feuilles": [
|
||
{
|
||
"nom": feuille.get("nom", ""),
|
||
"lignes": feuille.get("lignes", 0),
|
||
"colonnes_n": feuille.get("colonnes_n", 0),
|
||
"lignes_entete": sorted(
|
||
{
|
||
int(n)
|
||
for n in (
|
||
memoire[feuille.get("nom", "")]
|
||
if feuille.get("nom", "") in memoire
|
||
else (feuille.get("lignes_entete") or [])
|
||
)
|
||
}
|
||
),
|
||
"ligne_champs": feuille.get("ligne_champs"),
|
||
"entete_declaree": bool(feuille.get("entete_declaree")),
|
||
# D'où vient l'empan pré-coché : la table d'un lot, ou la
|
||
# mesure de ce fichier. L'écran le MARQUE, sans quoi une
|
||
# réponse héritée et un verdict se lisent pareil.
|
||
"entete_memorisee": feuille.get("nom", "") in memoire,
|
||
"lignes_sondees": list(feuille.get("lignes_sondees") or []),
|
||
"colonnes": [
|
||
{
|
||
"index": colonne.get("index"),
|
||
"etiquette": colonne.get("etiquette") or "",
|
||
"type": colonne.get("type", ""),
|
||
"remplies": colonne.get("remplies", 0),
|
||
"distinctes": colonne.get("distinctes", 0),
|
||
"exemples": list(colonne.get("exemples") or []),
|
||
"plancher": bool(colonne.get("plancher")),
|
||
}
|
||
for colonne in (feuille.get("colonnes") or [])
|
||
],
|
||
}
|
||
for feuille in (rapport.get("feuilles") or [])
|
||
],
|
||
}
|
||
|
||
|
||
def libelle_de_colonne(colonne, marque):
|
||
"""Les six cellules d'une ligne de colonne, prêtes à afficher.
|
||
|
||
`‹sans libellé›` plutôt qu'un blanc : c'est le cas de la majorité des
|
||
colonnes d'un export réel, et un blanc se lit comme une erreur
|
||
d'affichage plutôt que comme un fait.
|
||
"""
|
||
return (
|
||
marque,
|
||
str(colonne["index"]),
|
||
colonne["etiquette"] or t("‹no label›"),
|
||
colonne["type"],
|
||
str(colonne["remplies"]),
|
||
str(colonne["distinctes"]),
|
||
" · ".join(colonne["exemples"]),
|
||
)
|
||
|
||
|
||
def libelle_de_ligne_sondee(ligne, cochee):
|
||
"""Les cinq cellules d'une ligne sondée.
|
||
|
||
Les mesures sont montrées pour que l'opérateur voie POURQUOI la
|
||
mesure a tranché avant de la contredire — contredire un verdict qu'on
|
||
ne voit pas est un pari.
|
||
"""
|
||
mesure = ligne.get("mesure") or {}
|
||
return (
|
||
MARQUE_INTACTE if cochee else MARQUE_EN_PORTEE,
|
||
str(ligne["numero"]),
|
||
str(ligne.get("pleines", "")),
|
||
" · ".join(ligne.get("apercu") or []),
|
||
(
|
||
"%s %.2f %s %.2f %s %.2f"
|
||
% (
|
||
t("sig type"),
|
||
mesure.get("contraste", 0.0),
|
||
t("off-col"),
|
||
mesure.get("hors_colonne", 0.0),
|
||
t("shape"),
|
||
mesure.get("accord", 0.0),
|
||
)
|
||
if mesure
|
||
else ""
|
||
),
|
||
)
|
||
|
||
|
||
def cle_de_colonne(colonne):
|
||
"""Ce par quoi une réponse DÉSIGNE cette colonne : son INDEX.
|
||
|
||
Pas son étiquette, bien que ce soit ce que l'invite textuelle emploie.
|
||
Corriger l'empan d'en-tête RENOMME les colonnes — la ligne de champs
|
||
change, donc les étiquettes aussi — et une réponse portée par
|
||
l'étiquette tombait alors sur une autre colonne, ou sur aucune, sans
|
||
que rien ne le dise. L'index ne bouge pas.
|
||
|
||
L'ambiguïté qui oblige l'invite à préférer l'étiquette — « 1 » désigne
|
||
à la fois la colonne étiquetée « 1 » et la première — n'existe pas
|
||
ici : l'écran tient l'objet colonne, il ne tape pas une chaîne. La
|
||
spec les porte donc dans deux clés distinctes.
|
||
"""
|
||
return colonne["index"]
|
||
|
||
|
||
def basculer(ensemble, valeur):
|
||
"""Ajouter ou retirer, et dire ce qui en résulte.
|
||
|
||
Séparé du rendu pour être éprouvable : les méthodes de l'écran
|
||
appellent celle-ci PUIS redessinent, si bien que la décision se teste
|
||
sans monter un seul widget.
|
||
"""
|
||
if valeur in ensemble:
|
||
ensemble.discard(valeur)
|
||
return False
|
||
ensemble.add(valeur)
|
||
return True
|
||
|
||
|
||
def spec_depuis_etat(ctx, intactes, entetes, corrigees=()):
|
||
"""La spec rendue à l'appelant, en types SÉRIALISABLES.
|
||
|
||
Un `set` et un dict à clés tuple sont refusés par `json.dumps`, et
|
||
ces valeurs traversent le sous-processus du moteur : ce sont donc des
|
||
listes triées, et des dicts à clés `str`.
|
||
|
||
`entetes_par_feuille` porte CHAQUE feuille, y compris celles que
|
||
l'opérateur n'a pas touchées : c'est ce qui a été montré à l'écran, et
|
||
c'est là-dessus qu'il a consenti. Un empan vide y veut dire « pas
|
||
d'en-tête », là où une feuille ABSENTE voudrait dire « mesure-la ».
|
||
|
||
`entetes_corrigees` nomme les seules feuilles qu'il a VRAIMENT
|
||
changées, et c'est la seule chose qu'une table de lot doit retenir :
|
||
retenir un empan mesuré l'imposerait au fichier suivant même si sa
|
||
propre mesure disait autre chose — la mesure d'un fichier deviendrait
|
||
la loi de tout le lot.
|
||
"""
|
||
return {
|
||
"entetes_corrigees": sorted(corrigees),
|
||
"colonnes_intactes_index_par_feuille": {
|
||
nom: sorted(rangs) for nom, rangs in intactes.items() if rangs
|
||
},
|
||
"entetes_par_feuille": {
|
||
feuille["nom"]: sorted(entetes.get(feuille["nom"], set()))
|
||
for feuille in ctx["feuilles"]
|
||
},
|
||
}
|
||
|
||
|
||
def run_transform_form(ctx, run_app: bool = True):
|
||
"""Écran de périmètre. Rend une spec, None si annulé, {} pour les
|
||
invites textuelles. `run_app=False` rend l'instance sans la lancer
|
||
(tests headless)."""
|
||
# TOUS les imports textual ICI : le CLI importe ce module pour ses
|
||
# fonctions pures, et les libellés des BINDINGS ne doivent être
|
||
# évalués qu'à l'appel, après le choix de la langue.
|
||
from rich.text import Text
|
||
from textual.app import App, ComposeResult
|
||
from textual.containers import Horizontal, Vertical
|
||
from textual.widgets import DataTable, Footer, Header, OptionList, Static
|
||
from textual.widgets.option_list import Option
|
||
|
||
def brut(valeur):
|
||
"""Une chaîne rendue TELLE QUELLE, sans balisage console.
|
||
|
||
Rich lit `[x]` comme une balise de style et l'AVALE : la case
|
||
cochée n'apparaissait jamais, ni dans le tableau ni dans la
|
||
légende, et l'écran perdait son seul retour visuel. `[ ]` et
|
||
`[!]` survivaient, ce qui rendait le défaut illisible — la case
|
||
vide s'affichait, la case cochée disparaissait.
|
||
Ça ne concerne pas que les marques : la colonne des exemples
|
||
porte des valeurs du fichier, et l'une qui contient des crochets
|
||
serait mangée ou interprétée comme un style.
|
||
"""
|
||
return Text(str(valeur))
|
||
|
||
resultat = {"spec": None}
|
||
# Par feuille : les clés de colonne laissées intactes, et l'empan
|
||
# d'en-tête. L'empan part de ce que le moteur a MESURÉ ; les cases de
|
||
# colonne partent vides, une réponse n'étant jamais pré-cochée.
|
||
intactes = {f["nom"]: set() for f in ctx["feuilles"]}
|
||
entetes = {
|
||
f["nom"]: set(f["lignes_entete"] or ()) for f in ctx["feuilles"]
|
||
}
|
||
corrigees = set()
|
||
|
||
class Perimetre(App):
|
||
CSS = """
|
||
#tete { height: auto; padding: 0 1; color: $text-muted; }
|
||
#feuilles { width: 30; border: solid $panel; }
|
||
#colonnes { height: 3fr; min-height: 4; border: solid $accent; }
|
||
#sondees { height: 2fr; min-height: 4; border: solid $panel; }
|
||
#legende { height: auto; color: $text-muted; padding: 0 1; }
|
||
"""
|
||
BINDINGS = [
|
||
("space", "basculer", t("Toggle the current row")),
|
||
("f2", "intacte", t("Leave this column untouched")),
|
||
("f4", "rendre", t("Clear this sheet")),
|
||
("f5", "accepter", t("Accept")),
|
||
("f9", "invites", t("Fall back to text prompts")),
|
||
("escape", "annuler", t("Cancel")),
|
||
]
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.rang_feuille = 0
|
||
# Poser `highlighted` ÉMET l'événement de surbrillance,
|
||
# délivré PLUS TARD : avant que les tableaux soient dans le
|
||
# DOM au montage, et après qu'un verrou temporel se serait
|
||
# relâché. D'où deux parades distinctes — tolérer un DOM
|
||
# incomplet, et ne redessiner que si la feuille a VRAIMENT
|
||
# changé. Mesuré sans la seconde : 327 rendus au montage et
|
||
# 728 par touche, soit deux secondes par frappe.
|
||
self._pret = False
|
||
|
||
# -- montage ---------------------------------------------------- #
|
||
def compose(self) -> ComposeResult:
|
||
yield Header()
|
||
# Le CHEMIN du fichier, qui peut porter des crochets.
|
||
yield Static(
|
||
brut(" %s · %s" % (ctx["fichier"], ctx["format"])),
|
||
id="tete",
|
||
)
|
||
with Horizontal():
|
||
yield OptionList(id="feuilles")
|
||
with Vertical():
|
||
yield DataTable(id="colonnes")
|
||
yield DataTable(id="sondees")
|
||
yield Static(brut(" %s" % t(TEXTE_LEGENDE)), id="legende")
|
||
yield Footer()
|
||
|
||
def on_mount(self) -> None:
|
||
self.title = t("Transform — anonymisation scope")
|
||
liste = self.query_one("#feuilles", OptionList)
|
||
for feuille in ctx["feuilles"]:
|
||
liste.add_option(
|
||
Option(
|
||
brut(self._resume_feuille(feuille)), id=feuille["nom"]
|
||
)
|
||
)
|
||
colonnes = self.query_one("#colonnes", DataTable)
|
||
colonnes.cursor_type = "row"
|
||
self._poser_colonnes(colonnes, COLONNES_DU_PERIMETRE)
|
||
sondees = self.query_one("#sondees", DataTable)
|
||
sondees.cursor_type = "row"
|
||
self._poser_colonnes(sondees, COLONNES_SONDEES)
|
||
self._pret = True
|
||
if ctx["feuilles"]:
|
||
liste.highlighted = 0
|
||
self._remplir()
|
||
|
||
@staticmethod
|
||
def _poser_colonnes(tableau, colonnes):
|
||
"""Les colonnes d'un tableau, avec leur largeur.
|
||
|
||
`add_columns` ne prend pas de largeur : chaque colonne se pose
|
||
donc une par une.
|
||
"""
|
||
for cle, largeur in colonnes:
|
||
libelle = cle if cle in SIGNES else t(cle)
|
||
tableau.add_column(libelle, width=largeur)
|
||
|
||
# -- rendu ------------------------------------------------------ #
|
||
def _feuille(self):
|
||
if not ctx["feuilles"]:
|
||
return None
|
||
return ctx["feuilles"][self.rang_feuille]
|
||
|
||
@staticmethod
|
||
def _resume_feuille(feuille):
|
||
"""« Ventes 1 * » — le rang de la ligne de champs, ou « - ».
|
||
|
||
L'astérisque dit que l'opérateur a corrigé la mesure : sans
|
||
lui, une correction et un verdict se lisent pareil.
|
||
"""
|
||
empan = sorted(entetes.get(feuille["nom"], set()))
|
||
rang = str(max(empan)) if empan else "-"
|
||
if feuille["nom"] in corrigees:
|
||
marque = " " + MARQUE_CORRIGEE
|
||
elif feuille.get("entete_memorisee"):
|
||
# Hérité d'un fichier du même lot : montré, non subi.
|
||
marque = " " + MARQUE_DU_LOT
|
||
else:
|
||
marque = ""
|
||
return " %-20s %s%s" % (feuille["nom"][:20], rang, marque)
|
||
|
||
def _widget(self, selecteur, genre):
|
||
"""Le widget s'il est DÉJÀ dans le DOM, sinon None.
|
||
|
||
Textual délivre ses messages de façon asynchrone : un
|
||
événement de surbrillance émis au montage arrive avant que
|
||
les tableaux soient montés, et `query_one` y lève. Tolérer
|
||
l'absence vaut mieux qu'un verrou temporel, qui suppose un
|
||
ordre que rien ne garantit.
|
||
"""
|
||
trouves = self.query(selecteur)
|
||
return trouves.first(genre) if trouves else None
|
||
|
||
def _remplir(self):
|
||
feuille = self._feuille()
|
||
if feuille is None or not self._pret:
|
||
return
|
||
colonnes = self._widget("#colonnes", DataTable)
|
||
sondees_w = self._widget("#sondees", DataTable)
|
||
liste_w = self._widget("#feuilles", OptionList)
|
||
if colonnes is None or sondees_w is None or liste_w is None:
|
||
return
|
||
garde = colonnes.cursor_row
|
||
colonnes.clear()
|
||
for colonne in feuille["colonnes"]:
|
||
if colonne["plancher"]:
|
||
marque = MARQUE_PLANCHER
|
||
elif cle_de_colonne(colonne) in intactes[feuille["nom"]]:
|
||
marque = MARQUE_INTACTE
|
||
else:
|
||
marque = MARQUE_EN_PORTEE
|
||
colonnes.add_row(
|
||
*(brut(c) for c in libelle_de_colonne(colonne, marque))
|
||
)
|
||
if 0 <= garde < len(feuille["colonnes"]):
|
||
colonnes.move_cursor(row=garde)
|
||
|
||
sondees = sondees_w
|
||
garde = sondees.cursor_row
|
||
sondees.clear()
|
||
for ligne in feuille["lignes_sondees"]:
|
||
sondees.add_row(
|
||
*(
|
||
brut(c)
|
||
for c in libelle_de_ligne_sondee(
|
||
ligne,
|
||
ligne["numero"] in entetes[feuille["nom"]],
|
||
)
|
||
)
|
||
)
|
||
if 0 <= garde < len(feuille["lignes_sondees"]):
|
||
sondees.move_cursor(row=garde)
|
||
|
||
def _rafraichir_les_feuilles(self):
|
||
"""Le résumé d'une feuille change quand son EMPAN change.
|
||
|
||
Hors du rendu des tableaux, et appelé seulement par ce qui
|
||
touche l'empan : reconstruire la liste réémet la surbrillance,
|
||
et le faire à chaque rendu bouclait.
|
||
"""
|
||
liste = self._widget("#feuilles", OptionList)
|
||
if liste is None:
|
||
return
|
||
rang = liste.highlighted
|
||
liste.clear_options()
|
||
for autre in ctx["feuilles"]:
|
||
liste.add_option(
|
||
Option(brut(self._resume_feuille(autre)), id=autre["nom"])
|
||
)
|
||
if rang is not None:
|
||
liste.highlighted = rang
|
||
|
||
# -- navigation ------------------------------------------------- #
|
||
def on_option_list_option_highlighted(self, event) -> None:
|
||
if not self._pret:
|
||
return
|
||
noms = [f["nom"] for f in ctx["feuilles"]]
|
||
if event.option.id not in noms:
|
||
return
|
||
rang = noms.index(event.option.id)
|
||
# LA garde : reconstruire la liste réémet cet événement avec
|
||
# le MÊME rang. Ne redessiner que sur un changement réel coupe
|
||
# la boucle à sa racine, là où un verrou temporel échoue,
|
||
# l'événement étant délivré après son relâchement.
|
||
if rang == self.rang_feuille:
|
||
return
|
||
self.rang_feuille = rang
|
||
self._remplir()
|
||
|
||
# -- décisions -------------------------------------------------- #
|
||
def basculer_colonne(self, rang):
|
||
"""Une colonne PLANCHÉIÉE ne se coche pas.
|
||
|
||
Le plancher passe avant la réponse dans l'ordre des gardes :
|
||
une case qui ne changerait rien serait un mensonge.
|
||
"""
|
||
feuille = self._feuille()
|
||
if feuille is None or not (0 <= rang < len(feuille["colonnes"])):
|
||
return
|
||
colonne = feuille["colonnes"][rang]
|
||
if colonne["plancher"]:
|
||
self.notify(t("This column is floored: no answer applies."))
|
||
return
|
||
basculer(intactes[feuille["nom"]], cle_de_colonne(colonne))
|
||
self._remplir()
|
||
|
||
def basculer_ligne(self, rang):
|
||
feuille = self._feuille()
|
||
sondees = feuille["lignes_sondees"] if feuille else []
|
||
if not (0 <= rang < len(sondees)):
|
||
return
|
||
basculer(entetes[feuille["nom"]], sondees[rang]["numero"])
|
||
corrigees.add(feuille["nom"])
|
||
self._remplir()
|
||
self._rafraichir_les_feuilles()
|
||
|
||
def action_basculer(self) -> None:
|
||
"""L'espace agit sur le panneau qui a le focus."""
|
||
focus = self.focused
|
||
if focus is None:
|
||
return
|
||
if focus is self._widget("#sondees", DataTable):
|
||
self.basculer_ligne(focus.cursor_row)
|
||
elif focus is self._widget("#colonnes", DataTable):
|
||
self.basculer_colonne(focus.cursor_row)
|
||
|
||
def action_intacte(self) -> None:
|
||
colonnes = self._widget("#colonnes", DataTable)
|
||
if colonnes is not None:
|
||
self.basculer_colonne(colonnes.cursor_row)
|
||
|
||
def action_rendre(self) -> None:
|
||
"""Rendre la feuille à ce que le moteur a mesuré."""
|
||
feuille = self._feuille()
|
||
if feuille is None:
|
||
return
|
||
intactes[feuille["nom"]] = set()
|
||
entetes[feuille["nom"]] = set(feuille["lignes_entete"] or ())
|
||
corrigees.discard(feuille["nom"])
|
||
self._remplir()
|
||
self._rafraichir_les_feuilles()
|
||
|
||
def action_accepter(self) -> None:
|
||
resultat["spec"] = spec_depuis_etat(
|
||
ctx, intactes, entetes, corrigees
|
||
)
|
||
self.exit()
|
||
|
||
def action_invites(self) -> None:
|
||
"""Rendre `{}` et non `None` : l'appelant distingue
|
||
« poser les questions » de « annuler »."""
|
||
resultat["spec"] = {}
|
||
self.exit()
|
||
|
||
def action_annuler(self) -> None:
|
||
resultat["spec"] = None
|
||
self.exit()
|
||
|
||
app = Perimetre()
|
||
# Lus par les tests headless, qui pilotent l'app sans écran.
|
||
app._resultat = resultat
|
||
app._intactes = intactes
|
||
app._entetes = entetes
|
||
app._corrigees = corrigees
|
||
if not run_app:
|
||
return app
|
||
app.run()
|
||
return resultat["spec"]
|