[ADD] transform data : anonymiser un fichier externe ou une base Odoo
Partager un fichier ou une base demandait d'anonymiser à la main,
colonne par colonne, ou de ne pas partager. L'entrée ouvre un fichier
externe — Excel, Access, CSV, XML, JSON — ou une base Odoo, dit ce
qu'elle porte, puis en tire une copie. L'original n'est jamais touché.
Deux règles gouvernent ce qui sort. Une colonne n'est laissée intacte
que sur son CONTENU mesuré, hors onze étiquettes structurelles : décider
sur l'étiquette recopiait les colonnes les plus identifiantes en les
annonçant protégées. Et la copie est relue octet par octet, un classeur
portant de la donnée en une douzaine d'endroits hors des cellules.
Vérifié : 5 748 tests — 13 rouges, déjà rouges sur master.
--- EN ---
Sharing a file or a database meant anonymising by hand, column by
column, or not sharing. The entry opens an external file — Excel,
Access, CSV, XML, JSON — or an Odoo database, says what it holds, then
draws a copy of it. The original is never touched.
Two rules govern what comes out. A column is left alone on its measured
CONTENT, bar eleven structural labels: deciding on the label copied the
most identifying columns verbatim while announcing them as protected.
And the copy is reread byte by byte, a workbook holding data in a dozen
places that are not cells.
Checked: 5,748 tests — 13 red, already red on master.
Assisted-by: Claude Opus 5
2026-09-10 23:58:01 -04:00
|
|
|
|
#!/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.
|
|
|
|
|
|
"""
|
[REF] format : passer l'outillage et les tests sous ruff
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
2026-09-24 13:30:31 -04:00
|
|
|
|
|
[ADD] transform data : anonymiser un fichier externe ou une base Odoo
Partager un fichier ou une base demandait d'anonymiser à la main,
colonne par colonne, ou de ne pas partager. L'entrée ouvre un fichier
externe — Excel, Access, CSV, XML, JSON — ou une base Odoo, dit ce
qu'elle porte, puis en tire une copie. L'original n'est jamais touché.
Deux règles gouvernent ce qui sort. Une colonne n'est laissée intacte
que sur son CONTENU mesuré, hors onze étiquettes structurelles : décider
sur l'étiquette recopiait les colonnes les plus identifiantes en les
annonçant protégées. Et la copie est relue octet par octet, un classeur
portant de la donnée en une douzaine d'endroits hors des cellules.
Vérifié : 5 748 tests — 13 rouges, déjà rouges sur master.
--- EN ---
Sharing a file or a database meant anonymising by hand, column by
column, or not sharing. The entry opens an external file — Excel,
Access, CSV, XML, JSON — or an Odoo database, says what it holds, then
draws a copy of it. The original is never touched.
Two rules govern what comes out. A column is left alone on its measured
CONTENT, bar eleven structural labels: deciding on the label copied the
most identifying columns verbatim while announcing them as protected.
And the copy is reread byte by byte, a workbook holding data in a dozen
places that are not cells.
Checked: 5,748 tests — 13 red, already red on master.
Assisted-by: Claude Opus 5
2026-09-10 23:58:01 -04:00
|
|
|
|
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.
|
[REF] format : passer l'outillage et les tests sous ruff
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
2026-09-24 13:30:31 -04:00
|
|
|
|
from rich.text import Text
|
[ADD] transform data : anonymiser un fichier externe ou une base Odoo
Partager un fichier ou une base demandait d'anonymiser à la main,
colonne par colonne, ou de ne pas partager. L'entrée ouvre un fichier
externe — Excel, Access, CSV, XML, JSON — ou une base Odoo, dit ce
qu'elle porte, puis en tire une copie. L'original n'est jamais touché.
Deux règles gouvernent ce qui sort. Une colonne n'est laissée intacte
que sur son CONTENU mesuré, hors onze étiquettes structurelles : décider
sur l'étiquette recopiait les colonnes les plus identifiantes en les
annonçant protégées. Et la copie est relue octet par octet, un classeur
portant de la donnée en une douzaine d'endroits hors des cellules.
Vérifié : 5 748 tests — 13 rouges, déjà rouges sur master.
--- EN ---
Sharing a file or a database meant anonymising by hand, column by
column, or not sharing. The entry opens an external file — Excel,
Access, CSV, XML, JSON — or an Odoo database, says what it holds, then
draws a copy of it. The original is never touched.
Two rules govern what comes out. A column is left alone on its measured
CONTENT, bar eleven structural labels: deciding on the label copied the
most identifying columns verbatim while announcing them as protected.
And the copy is reread byte by byte, a workbook holding data in a dozen
places that are not cells.
Checked: 5,748 tests — 13 red, already red on master.
Assisted-by: Claude Opus 5
2026-09-10 23:58:01 -04:00
|
|
|
|
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"]
|