[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
This commit is contained in:
Mathieu Benoit 2026-09-10 23:58:01 -04:00
parent a49d8178a2
commit 04b9717e96
18 changed files with 18543 additions and 53 deletions

View file

@ -46,6 +46,12 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A declared READ-ONLY context per tool, files and allowlisted commands, shown and confirmed before the first send, capped in size and duration, and scanned for identifying data. The scan's honest limit is stated: it sees addresses, e-mails and account paths, not names - A declared READ-ONLY context per tool, files and allowlisted commands, shown and confirmed before the first send, capped in size and duration, and scanned for identifying data. The scan's honest limit is stated: it sees addresses, e-mails and account paths, not names
- `Execute › GPT code › Claude Code` — list the machine's sessions, ask one a question with read-only tools, or resume one in its own terminal. A copy is branched by default, since two writers on one session lose a branch - `Execute › GPT code › Claude Code` — list the machine's sessions, ask one a question with read-only tools, or resume one in its own terminal. A copy is branched by default, since two writers on one session lose a branch
- `check_comment_hygiene.py` signals a fully qualified machine name in a comment, as a re-read signal and never a finding: a BARE host name is mechanically indistinguishable from an ordinary word, so the absence of a signal proves nothing about names. The copyright header, the RFC 2606 domains and any address carried by a URL are left alone - `check_comment_hygiene.py` signals a fully qualified machine name in a comment, as a re-read signal and never a finding: a BARE host name is mechanically indistinguishable from an ordinary word, so the absence of a signal proves nothing about names. The copyright header, the RFC 2606 domains and any address carried by a URL are left alone
- Transform data, a menu entry that opens an external file — Excel, Access, CSV, XML or JSON — reports what it holds, then draws an anonymised copy of it. The original is never touched. Numbers are drawn from the measured extent of their own column rather than a fixed 0-1000 range, which turns a tax rate into 743 and a year into 12; they are drawn without replacement, since a hundred distinct keys in a range of a hundred otherwise yield sixty-six outputs and a fixture that no longer reimports. Text takes a French word from a local dictionary of 1366, assigned by a counter rather than a draw, and a portable mapping table gives the same client the same word across a whole batch
- Eleven structural labels are left alone on their NAME — `id`, `create_uid`, `sequence`, `active` and the rest, plus anything ending in `/id` or `/.id` — since a test set has to keep working. Every OTHER column is judged on its measured CONTENT, never on the strength of its name. In an import-ready export, `partner_id` holds the relation's display name and `display_name` is the data itself, so deciding on the label copied the most identifying columns of the file verbatim while announcing them as protected
- The anonymiser rereads the bytes it has just written and refuses the copy when a value it announced as replaced survives in it. A workbook holds data in a dozen places that are not cells: a pivot cache and an external link each carry an entire copy of the source, a chart caches its categories and its axis titles, a custom number format carries a label, a comment carries its author, a cell hyperlink carries a path. The guard depends on no list of those, so a place nobody thought to clean produces a refusal instead of a silence — and what stays by decision, a header row or a column left alone, is named on screen before anything is written
- Transform data anonymises an Odoo database too, the entry asking for the SOURCE rather than the format: an external file, a live database, or a backup zip taken from `image_db/`. A zip is never modified — it is restored into a database, anonymised there, then dumped into a NEW zip by Odoo's own backup, so the dump, the filestore and the manifest are written by the software that will read them back. A live database is modified ITSELF, which is said before the work and not after, and nothing is drawn when the write did not happen: declining otherwise handed back a zip of the untouched database, announced as anonymised. A register records which databases the entry produced, since the server does not
- An anonymised number can keep its digit count, on the file anonymiser and on `anonymize.py` alike (`--keep-digits`): 8839 then draws in 1000..9999, so the copy keeps columns of the same width, which an export read by eye or imported into a bounded field asks for. Each value keeps ITS own width and not its column's — 7 stays at one digit where 12 keeps two — and below the unit the rule does not apply, 0.15 having no digit before the point. The option trades one guarantee for another: the measured extent no longer bounds anything, so a rate or a year can leave its range, and the preview SAYS so rather than letting it be found at reimport. Uniqueness is what yields when a band fills up, a duplicate of the same width beating a value of a different one, and the band never exceeds what the type RECEIVING the draw holds, which is not always the column's: an Odoo `integer` over a column PostgreSQL did not create as an integer — what an upgraded base carries — is poured into `numeric`, which has no ceiling, where `integer` stops at 2 147 483 647 and a draw past it aborts the whole write, that write being one transaction
- The anonymiser asks four questions instead of eleven, seven of the eleven having had an obvious default. Answering them one by one to arrive at the same place makes prompts be passed by reflex, and a prompt passed by reflex consents to nothing; they now live behind « Advanced options? ». The short path DECLARES what it takes, in sentences rather than in yes/no, since those are decisions taken and not questions whose answer was lost. Macros and charts are asked on both paths: they are not visible in the cells
<!-- [fr] --> <!-- [fr] -->
@ -64,7 +70,12 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un contexte LECTURE SEULE déclaré par outil, fichiers et commandes autorisées, montré et confirmé avant le premier envoi, borné en taille et en durée, et balayé à la recherche de données identifiantes. La limite du balayage est dite : il voit les adresses, les courriels et les chemins de compte, pas les noms - Un contexte LECTURE SEULE déclaré par outil, fichiers et commandes autorisées, montré et confirmé avant le premier envoi, borné en taille et en durée, et balayé à la recherche de données identifiantes. La limite du balayage est dite : il voit les adresses, les courriels et les chemins de compte, pas les noms
- `Exécution › GPT code › Claude Code` — lister les sessions de la machine, en interroger une avec des outils en lecture seule, ou la reprendre dans son propre terminal. Une copie est branchée par défaut, deux écritures sur une même session perdant une branche - `Exécution › GPT code › Claude Code` — lister les sessions de la machine, en interroger une avec des outils en lecture seule, ou la reprendre dans son propre terminal. Une copie est branchée par défaut, deux écritures sur une même session perdant une branche
- `check_comment_hygiene.py` signale un nom de machine pleinement qualifié dans un commentaire, en signal à relire et jamais en trouvaille : un nom d'hôte NU ne se distingue mécaniquement pas d'un mot ordinaire, donc l'absence de signal ne prouve rien sur les noms. L'en-tête de copyright, les domaines de la RFC 2606 et toute adresse portée par une URL sont laissés tranquilles - `check_comment_hygiene.py` signale un nom de machine pleinement qualifié dans un commentaire, en signal à relire et jamais en trouvaille : un nom d'hôte NU ne se distingue mécaniquement pas d'un mot ordinaire, donc l'absence de signal ne prouve rien sur les noms. L'en-tête de copyright, les domaines de la RFC 2606 et toute adresse portée par une URL sont laissés tranquilles
- Transform data, une entrée de menu qui ouvre un fichier externe — Excel, Access, CSV, XML ou JSON —, rapporte ce qu'il porte, puis en tire une copie anonymisée. L'original n'est jamais touché. Les nombres sont tirés dans l'étendue mesurée de leur propre colonne plutôt que dans un intervalle fixe de 0 à 1000, qui rend un taux de TVA à 743 et une année à 12 ; ils sont tirés sans remise, cent clés distinctes dans une étendue de cent ne rendant sinon que soixante-six sorties, et une fixture qui ne se réimporte plus. Le texte prend un mot français dans un dictionnaire local de 1366, attribué par un compteur et non par un tirage, et une table de correspondance portable donne au même client le même mot sur tout un lot
- Onze étiquettes structurelles sont laissées intactes sur leur NOM — `id`, `create_uid`, `sequence`, `active` et les autres, plus tout ce qui finit en `/id` ou `/.id` —, un jeu de test devant continuer de fonctionner. Toute AUTRE colonne est jugée sur son CONTENU mesuré, jamais sur la seule foi de son nom. Dans un export import-compatible, `partner_id` porte le nom affiché de la relation et `display_name` est la donnée elle-même : décider sur l'étiquette recopiait textuellement les colonnes les plus identifiantes du fichier en les annonçant comme protégées
- L'anonymiseur relit les octets qu'il vient d'écrire et refuse la copie dès qu'une valeur annoncée comme remplacée y subsiste. Un classeur porte de la donnée dans une douzaine d'endroits qui ne sont pas des cellules : un cache de tableau croisé et un lien externe en portent chacun une copie entière, un graphique met ses catégories et ses titres d'axes en cache, un format de nombre personnalisé porte un libellé, un commentaire porte son auteur, un hyperlien de cellule porte un chemin. Le filet ne dépend d'aucune liste de ces endroits, si bien qu'un endroit que personne n'a pensé à nettoyer produit un refus et non un silence — et ce qui reste par décision, une ligne d'en-tête ou une colonne laissée intacte, est nommé à l'écran avant que rien ne soit écrit
- Transform data anonymise aussi une base Odoo, l'entrée demandant la SOURCE plutôt que le format : un fichier externe, une base vivante, ou un zip de sauvegarde pris dans `image_db/`. Un zip n'est jamais modifié — il est restauré dans une base, anonymisé là, puis vidé dans un zip NEUF par la sauvegarde d'Odoo lui-même, si bien que le dump, le filestore et le manifeste sont écrits par le logiciel qui les relira. Une base vivante est modifiée ELLE-MÊME, ce qui est dit avant le travail et non après, et rien n'est tiré lorsque l'écriture n'a pas eu lieu : un renoncement rendait sinon un zip de la base intacte, annoncé comme anonymisé. Un registre inscrit les bases que l'entrée a produites, le serveur ne le disant pas
- Un nombre anonymisé peut garder son nombre de chiffres, sur l'anonymiseur de fichiers comme sur `anonymize.py` (`--keep-digits`) : 8839 tire alors dans 1000..9999, si bien que la copie garde des colonnes de la même largeur, ce qu'un export relu à l'œil ou importé dans un champ borné demande. Chaque valeur garde SA largeur et non celle de sa colonne — 7 reste à un chiffre là où 12 en garde deux — et sous l'unité la règle ne s'applique pas, 0,15 n'ayant pas de chiffre avant la virgule. L'option échange une garantie contre une autre : l'étendue mesurée ne borne plus rien, donc un taux ou une année peut sortir de sa plage, et l'aperçu le DIT au lieu de le laisser découvrir à la réimportation. C'est l'unicité qui cède quand une bande se remplit, un doublon de la même largeur valant mieux qu'une valeur d'une autre, et la bande ne dépasse jamais ce que tient le type qui ACCUEILLE le tirage, lequel n'est pas toujours celui de la colonne : un `integer` d'Odoo posé sur une colonne que PostgreSQL n'a pas créée entière — ce qu'une base montée de version porte — se coule en `numeric`, qui n'a pas de plafond, là où `integer` s'arrête à 2 147 483 647 et où un tirage au-delà fait retomber toute l'écriture, celle-ci tenant en une transaction
- L'anonymiseur pose quatre questions au lieu de onze, sept des onze ayant eu un défaut évident. Les répondre une par une pour arriver au même endroit fait passer les invites par réflexe, et une invite qu'on passe par réflexe ne consent à rien : elles vivent désormais derrière « Options avancées ? ». Le chemin court DIT ce qu'il prend, en phrases et non en oui/non, puisque ce sont des décisions prises et non des questions dont on aurait perdu la réponse. Macros et graphiques sont demandés dans les deux chemins : ils ne se voient pas dans les cellules
<!-- [en] --> <!-- [en] -->
## Changed ## Changed
<!-- [fr] --> <!-- [fr] -->
@ -89,6 +100,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Eleven submenus now leave their segment in the breadcrumb, and navigation telemetry stops filing them as commands under their raw method name - Eleven submenus now leave their segment in the breadcrumb, and navigation telemetry stops filing them as commands under their raw method name
- The three readers of `~/.ssh/config` agree on what a machine name is: an alias declared with a lowercase `host` is seen, a tab separates as legally as a space, and a negated `!name` pattern is no longer taken for a machine to connect to - The three readers of `~/.ssh/config` agree on what a machine name is: an alias declared with a lowercase `host` is seen, a tab separates as legally as a space, and a negated `!name` pattern is no longer taken for a machine to connect to
- Three translation keys declared twice are gone, and a check refuses the next one: a repeated key silently overwrites the previous, which had already cost a menu label - Three translation keys declared twice are gone, and a check refuses the next one: a repeated key silently overwrites the previous, which had already cost a menu label
- `anonymize.py` abstains from a unique numeric column and NAMES it in the report. A draw guarantees nothing on a unique column, and a number has no way out where text has one — appending the id carries uniqueness for text, but would change a number's magnitude. Two rows drawing the same number failed the UPDATE and, the whole run being one transaction, took the entire anonymisation with them. Without the report naming it, an identifying column stays in the clear with nothing saying so
- Restoring a backup no longer raises `AttributeError` before running the command: `_monitoring_restore` read `self._execute` where `TODO` sets `self.execute`, and both entries leading there — the local backup and the remote one — were broken. A check refuses the next one: every `self.X` that `TODO` READS must be set by TODO or by one of its bases. Searching the name across `script/todo/` did not see the fault, another object of the package setting `_execute`; it has to be searched in the classes TODO INHERITS from
<!-- [fr] --> <!-- [fr] -->
@ -98,6 +111,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Onze sous-menus laissent désormais leur segment dans le fil d'Ariane, et la télémétrie de navigation cesse de les classer comme des commandes sous leur nom de méthode brut - Onze sous-menus laissent désormais leur segment dans le fil d'Ariane, et la télémétrie de navigation cesse de les classer comme des commandes sous leur nom de méthode brut
- Les trois lecteurs de `~/.ssh/config` s'accordent sur ce qu'est un nom de machine : un alias déclaré par un `host` en minuscules est vu, une tabulation sépare aussi légalement qu'un espace, et un motif nié `!nom` n'est plus pris pour une machine à joindre - Les trois lecteurs de `~/.ssh/config` s'accordent sur ce qu'est un nom de machine : un alias déclaré par un `host` en minuscules est vu, une tabulation sépare aussi légalement qu'un espace, et un motif nié `!nom` n'est plus pris pour une machine à joindre
- Trois clés de traduction déclarées deux fois ont disparu, et un contrôle refuse la suivante : une clé répétée écrase la précédente en silence, ce qui avait déjà coûté une étiquette de menu - Trois clés de traduction déclarées deux fois ont disparu, et un contrôle refuse la suivante : une clé répétée écrase la précédente en silence, ce qui avait déjà coûté une étiquette de menu
- `anonymize.py` s'abstient sur une colonne numérique unique et la NOMME dans le rapport. Un tirage ne garantit rien sur une colonne unique, et un nombre n'a pas l'issue qu'a le texte — coller l'id porte l'unicité pour du texte, mais changerait la grandeur d'un nombre. Deux lignes tirant le même nombre faisaient échouer l'UPDATE et, transaction unique oblige, emportaient TOUTE l'anonymisation. Sans que le rapport la nomme, une colonne identifiante reste en clair sans que rien ne le dise
- Restaurer une sauvegarde ne lève plus `AttributeError` avant de lancer la commande : `_monitoring_restore` lisait `self._execute` là où `TODO` pose `self.execute`, et les deux entrées qui y mènent — la sauvegarde locale et la distante — étaient cassées. Un contrôle refuse le suivant : tout `self.X` que `TODO` LIT doit être posé par TODO ou par une de ses bases. Chercher le nom dans tout `script/todo/` ne voyait pas la faute, un autre objet du paquet posant bien `_execute` ; il faut le chercher dans les classes dont TODO HÉRITE
<!-- [en] --> <!-- [en] -->
## Removed ## Removed

View file

@ -26,7 +26,12 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un contexte LECTURE SEULE déclaré par outil, fichiers et commandes autorisées, montré et confirmé avant le premier envoi, borné en taille et en durée, et balayé à la recherche de données identifiantes. La limite du balayage est dite : il voit les adresses, les courriels et les chemins de compte, pas les noms - Un contexte LECTURE SEULE déclaré par outil, fichiers et commandes autorisées, montré et confirmé avant le premier envoi, borné en taille et en durée, et balayé à la recherche de données identifiantes. La limite du balayage est dite : il voit les adresses, les courriels et les chemins de compte, pas les noms
- `Exécution › GPT code › Claude Code` — lister les sessions de la machine, en interroger une avec des outils en lecture seule, ou la reprendre dans son propre terminal. Une copie est branchée par défaut, deux écritures sur une même session perdant une branche - `Exécution › GPT code › Claude Code` — lister les sessions de la machine, en interroger une avec des outils en lecture seule, ou la reprendre dans son propre terminal. Une copie est branchée par défaut, deux écritures sur une même session perdant une branche
- `check_comment_hygiene.py` signale un nom de machine pleinement qualifié dans un commentaire, en signal à relire et jamais en trouvaille : un nom d'hôte NU ne se distingue mécaniquement pas d'un mot ordinaire, donc l'absence de signal ne prouve rien sur les noms. L'en-tête de copyright, les domaines de la RFC 2606 et toute adresse portée par une URL sont laissés tranquilles - `check_comment_hygiene.py` signale un nom de machine pleinement qualifié dans un commentaire, en signal à relire et jamais en trouvaille : un nom d'hôte NU ne se distingue mécaniquement pas d'un mot ordinaire, donc l'absence de signal ne prouve rien sur les noms. L'en-tête de copyright, les domaines de la RFC 2606 et toute adresse portée par une URL sont laissés tranquilles
- Transform data, une entrée de menu qui ouvre un fichier externe — Excel, Access, CSV, XML ou JSON —, rapporte ce qu'il porte, puis en tire une copie anonymisée. L'original n'est jamais touché. Les nombres sont tirés dans l'étendue mesurée de leur propre colonne plutôt que dans un intervalle fixe de 0 à 1000, qui rend un taux de TVA à 743 et une année à 12 ; ils sont tirés sans remise, cent clés distinctes dans une étendue de cent ne rendant sinon que soixante-six sorties, et une fixture qui ne se réimporte plus. Le texte prend un mot français dans un dictionnaire local de 1366, attribué par un compteur et non par un tirage, et une table de correspondance portable donne au même client le même mot sur tout un lot
- Onze étiquettes structurelles sont laissées intactes sur leur NOM — `id`, `create_uid`, `sequence`, `active` et les autres, plus tout ce qui finit en `/id` ou `/.id` —, un jeu de test devant continuer de fonctionner. Toute AUTRE colonne est jugée sur son CONTENU mesuré, jamais sur la seule foi de son nom. Dans un export import-compatible, `partner_id` porte le nom affiché de la relation et `display_name` est la donnée elle-même : décider sur l'étiquette recopiait textuellement les colonnes les plus identifiantes du fichier en les annonçant comme protégées
- L'anonymiseur relit les octets qu'il vient d'écrire et refuse la copie dès qu'une valeur annoncée comme remplacée y subsiste. Un classeur porte de la donnée dans une douzaine d'endroits qui ne sont pas des cellules : un cache de tableau croisé et un lien externe en portent chacun une copie entière, un graphique met ses catégories et ses titres d'axes en cache, un format de nombre personnalisé porte un libellé, un commentaire porte son auteur, un hyperlien de cellule porte un chemin. Le filet ne dépend d'aucune liste de ces endroits, si bien qu'un endroit que personne n'a pensé à nettoyer produit un refus et non un silence — et ce qui reste par décision, une ligne d'en-tête ou une colonne laissée intacte, est nommé à l'écran avant que rien ne soit écrit
- Transform data anonymise aussi une base Odoo, l'entrée demandant la SOURCE plutôt que le format : un fichier externe, une base vivante, ou un zip de sauvegarde pris dans `image_db/`. Un zip n'est jamais modifié — il est restauré dans une base, anonymisé là, puis vidé dans un zip NEUF par la sauvegarde d'Odoo lui-même, si bien que le dump, le filestore et le manifeste sont écrits par le logiciel qui les relira. Une base vivante est modifiée ELLE-MÊME, ce qui est dit avant le travail et non après, et rien n'est tiré lorsque l'écriture n'a pas eu lieu : un renoncement rendait sinon un zip de la base intacte, annoncé comme anonymisé. Un registre inscrit les bases que l'entrée a produites, le serveur ne le disant pas
- Un nombre anonymisé peut garder son nombre de chiffres, sur l'anonymiseur de fichiers comme sur `anonymize.py` (`--keep-digits`) : 8839 tire alors dans 1000..9999, si bien que la copie garde des colonnes de la même largeur, ce qu'un export relu à l'œil ou importé dans un champ borné demande. Chaque valeur garde SA largeur et non celle de sa colonne — 7 reste à un chiffre là où 12 en garde deux — et sous l'unité la règle ne s'applique pas, 0,15 n'ayant pas de chiffre avant la virgule. L'option échange une garantie contre une autre : l'étendue mesurée ne borne plus rien, donc un taux ou une année peut sortir de sa plage, et l'aperçu le DIT au lieu de le laisser découvrir à la réimportation. C'est l'unicité qui cède quand une bande se remplit, un doublon de la même largeur valant mieux qu'une valeur d'une autre, et la bande ne dépasse jamais ce que tient le type qui ACCUEILLE le tirage, lequel n'est pas toujours celui de la colonne : un `integer` d'Odoo posé sur une colonne que PostgreSQL n'a pas créée entière — ce qu'une base montée de version porte — se coule en `numeric`, qui n'a pas de plafond, là où `integer` s'arrête à 2 147 483 647 et où un tirage au-delà fait retomber toute l'écriture, celle-ci tenant en une transaction
- L'anonymiseur pose quatre questions au lieu de onze, sept des onze ayant eu un défaut évident. Les répondre une par une pour arriver au même endroit fait passer les invites par réflexe, et une invite qu'on passe par réflexe ne consent à rien : elles vivent désormais derrière « Options avancées ? ». Le chemin court DIT ce qu'il prend, en phrases et non en oui/non, puisque ce sont des décisions prises et non des questions dont on aurait perdu la réponse. Macros et graphiques sont demandés dans les deux chemins : ils ne se voient pas dans les cellules
## Modifié ## Modifié
- `Assistant › [1]` n'envoie plus chaque question à une seule API distante sur un modèle figé : elle interroge le serveur configuré, et ne retombe sur le distant que lorsqu'aucun serveur local ne répond - `Assistant › [1]` n'envoie plus chaque question à une seule API distante sur un modèle figé : elle interroge le serveur configuré, et ne retombe sur le distant que lorsqu'aucun serveur local ne répond
@ -39,6 +44,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Onze sous-menus laissent désormais leur segment dans le fil d'Ariane, et la télémétrie de navigation cesse de les classer comme des commandes sous leur nom de méthode brut - Onze sous-menus laissent désormais leur segment dans le fil d'Ariane, et la télémétrie de navigation cesse de les classer comme des commandes sous leur nom de méthode brut
- Les trois lecteurs de `~/.ssh/config` s'accordent sur ce qu'est un nom de machine : un alias déclaré par un `host` en minuscules est vu, une tabulation sépare aussi légalement qu'un espace, et un motif nié `!nom` n'est plus pris pour une machine à joindre - Les trois lecteurs de `~/.ssh/config` s'accordent sur ce qu'est un nom de machine : un alias déclaré par un `host` en minuscules est vu, une tabulation sépare aussi légalement qu'un espace, et un motif nié `!nom` n'est plus pris pour une machine à joindre
- Trois clés de traduction déclarées deux fois ont disparu, et un contrôle refuse la suivante : une clé répétée écrase la précédente en silence, ce qui avait déjà coûté une étiquette de menu - Trois clés de traduction déclarées deux fois ont disparu, et un contrôle refuse la suivante : une clé répétée écrase la précédente en silence, ce qui avait déjà coûté une étiquette de menu
- `anonymize.py` s'abstient sur une colonne numérique unique et la NOMME dans le rapport. Un tirage ne garantit rien sur une colonne unique, et un nombre n'a pas l'issue qu'a le texte — coller l'id porte l'unicité pour du texte, mais changerait la grandeur d'un nombre. Deux lignes tirant le même nombre faisaient échouer l'UPDATE et, transaction unique oblige, emportaient TOUTE l'anonymisation. Sans que le rapport la nomme, une colonne identifiante reste en clair sans que rien ne le dise
- Restaurer une sauvegarde ne lève plus `AttributeError` avant de lancer la commande : `_monitoring_restore` lisait `self._execute` là où `TODO` pose `self.execute`, et les deux entrées qui y mènent — la sauvegarde locale et la distante — étaient cassées. Un contrôle refuse le suivant : tout `self.X` que `TODO` LIT doit être posé par TODO ou par une de ses bases. Chercher le nom dans tout `script/todo/` ne voyait pas la faute, un autre objet du paquet posant bien `_execute` ; il faut le chercher dans les classes dont TODO HÉRITE
## Retiré ## Retiré

View file

@ -26,6 +26,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A declared READ-ONLY context per tool, files and allowlisted commands, shown and confirmed before the first send, capped in size and duration, and scanned for identifying data. The scan's honest limit is stated: it sees addresses, e-mails and account paths, not names - A declared READ-ONLY context per tool, files and allowlisted commands, shown and confirmed before the first send, capped in size and duration, and scanned for identifying data. The scan's honest limit is stated: it sees addresses, e-mails and account paths, not names
- `Execute › GPT code › Claude Code` — list the machine's sessions, ask one a question with read-only tools, or resume one in its own terminal. A copy is branched by default, since two writers on one session lose a branch - `Execute › GPT code › Claude Code` — list the machine's sessions, ask one a question with read-only tools, or resume one in its own terminal. A copy is branched by default, since two writers on one session lose a branch
- `check_comment_hygiene.py` signals a fully qualified machine name in a comment, as a re-read signal and never a finding: a BARE host name is mechanically indistinguishable from an ordinary word, so the absence of a signal proves nothing about names. The copyright header, the RFC 2606 domains and any address carried by a URL are left alone - `check_comment_hygiene.py` signals a fully qualified machine name in a comment, as a re-read signal and never a finding: a BARE host name is mechanically indistinguishable from an ordinary word, so the absence of a signal proves nothing about names. The copyright header, the RFC 2606 domains and any address carried by a URL are left alone
- Transform data, a menu entry that opens an external file — Excel, Access, CSV, XML or JSON — reports what it holds, then draws an anonymised copy of it. The original is never touched. Numbers are drawn from the measured extent of their own column rather than a fixed 0-1000 range, which turns a tax rate into 743 and a year into 12; they are drawn without replacement, since a hundred distinct keys in a range of a hundred otherwise yield sixty-six outputs and a fixture that no longer reimports. Text takes a French word from a local dictionary of 1366, assigned by a counter rather than a draw, and a portable mapping table gives the same client the same word across a whole batch
- Eleven structural labels are left alone on their NAME — `id`, `create_uid`, `sequence`, `active` and the rest, plus anything ending in `/id` or `/.id` — since a test set has to keep working. Every OTHER column is judged on its measured CONTENT, never on the strength of its name. In an import-ready export, `partner_id` holds the relation's display name and `display_name` is the data itself, so deciding on the label copied the most identifying columns of the file verbatim while announcing them as protected
- The anonymiser rereads the bytes it has just written and refuses the copy when a value it announced as replaced survives in it. A workbook holds data in a dozen places that are not cells: a pivot cache and an external link each carry an entire copy of the source, a chart caches its categories and its axis titles, a custom number format carries a label, a comment carries its author, a cell hyperlink carries a path. The guard depends on no list of those, so a place nobody thought to clean produces a refusal instead of a silence — and what stays by decision, a header row or a column left alone, is named on screen before anything is written
- Transform data anonymises an Odoo database too, the entry asking for the SOURCE rather than the format: an external file, a live database, or a backup zip taken from `image_db/`. A zip is never modified — it is restored into a database, anonymised there, then dumped into a NEW zip by Odoo's own backup, so the dump, the filestore and the manifest are written by the software that will read them back. A live database is modified ITSELF, which is said before the work and not after, and nothing is drawn when the write did not happen: declining otherwise handed back a zip of the untouched database, announced as anonymised. A register records which databases the entry produced, since the server does not
- An anonymised number can keep its digit count, on the file anonymiser and on `anonymize.py` alike (`--keep-digits`): 8839 then draws in 1000..9999, so the copy keeps columns of the same width, which an export read by eye or imported into a bounded field asks for. Each value keeps ITS own width and not its column's — 7 stays at one digit where 12 keeps two — and below the unit the rule does not apply, 0.15 having no digit before the point. The option trades one guarantee for another: the measured extent no longer bounds anything, so a rate or a year can leave its range, and the preview SAYS so rather than letting it be found at reimport. Uniqueness is what yields when a band fills up, a duplicate of the same width beating a value of a different one, and the band never exceeds what the type RECEIVING the draw holds, which is not always the column's: an Odoo `integer` over a column PostgreSQL did not create as an integer — what an upgraded base carries — is poured into `numeric`, which has no ceiling, where `integer` stops at 2 147 483 647 and a draw past it aborts the whole write, that write being one transaction
- The anonymiser asks four questions instead of eleven, seven of the eleven having had an obvious default. Answering them one by one to arrive at the same place makes prompts be passed by reflex, and a prompt passed by reflex consents to nothing; they now live behind « Advanced options? ». The short path DECLARES what it takes, in sentences rather than in yes/no, since those are decisions taken and not questions whose answer was lost. Macros and charts are asked on both paths: they are not visible in the cells
## Changed ## Changed
@ -39,6 +45,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Eleven submenus now leave their segment in the breadcrumb, and navigation telemetry stops filing them as commands under their raw method name - Eleven submenus now leave their segment in the breadcrumb, and navigation telemetry stops filing them as commands under their raw method name
- The three readers of `~/.ssh/config` agree on what a machine name is: an alias declared with a lowercase `host` is seen, a tab separates as legally as a space, and a negated `!name` pattern is no longer taken for a machine to connect to - The three readers of `~/.ssh/config` agree on what a machine name is: an alias declared with a lowercase `host` is seen, a tab separates as legally as a space, and a negated `!name` pattern is no longer taken for a machine to connect to
- Three translation keys declared twice are gone, and a check refuses the next one: a repeated key silently overwrites the previous, which had already cost a menu label - Three translation keys declared twice are gone, and a check refuses the next one: a repeated key silently overwrites the previous, which had already cost a menu label
- `anonymize.py` abstains from a unique numeric column and NAMES it in the report. A draw guarantees nothing on a unique column, and a number has no way out where text has one — appending the id carries uniqueness for text, but would change a number's magnitude. Two rows drawing the same number failed the UPDATE and, the whole run being one transaction, took the entire anonymisation with them. Without the report naming it, an identifying column stays in the clear with nothing saying so
- Restoring a backup no longer raises `AttributeError` before running the command: `_monitoring_restore` read `self._execute` where `TODO` sets `self.execute`, and both entries leading there — the local backup and the remote one — were broken. A check refuses the next one: every `self.X` that `TODO` READS must be set by TODO or by one of its bases. Searching the name across `script/todo/` did not see the fault, another object of the package setting `_execute`; it has to be searched in the classes TODO INHERITS from
## Removed ## Removed

View file

@ -0,0 +1,28 @@
# Lecteurs de fichiers externes pour « TODO › Execute › Transform data ».
#
# Bornes PROPRES à ce venv et indépendantes de .odoo-version : chacune
# existe pour ce que la bibliothèque fait ici, pas parce que
# pyproject.toml la déclare. Changer de version d'Odoo ne doit pas
# déplacer ce fichier, ni l'inverse.
#
# La borne openpyxl est EXACTE et non minimale : le nettoyage hors
# cellules touche _external_links, _charts, _pivots, _images et
# conditional_formatting._cf_rules, qui n'ont pas d'équivalent public en
# 3.1.2. Le test de fuite de test_transform_external.py est ce qui
# surveille cette borne à la montée de version.
openpyxl==3.1.2
xlsxwriter==3.1.9
xlrd==2.0.1
access-parser==0.0.6
chardet==5.2.0
defusedxml==0.7.1
# SANS Pillow, openpyxl retire xl/media/ en silence et ws._images reste
# toujours vide : les images disparaissent de la copie sans que rien ne
# puisse les compter ni l'annoncer. Il les rend VISIBLES au moteur, qui
# les efface ensuite.
Pillow==12.2.0
# 1404 mots français, hors réseau, un seul fichier. Le dictionnaire de la
# règle du texte ; import « randomwordfr », pas le nom du paquet. Déjà
# dans requirement/erplibre_require-ments.txt, donc .venv.erplibre l'a —
# ce venv-ci en a besoin pour son propre compte.
python-randomword-fr==1.0.0

View file

@ -64,6 +64,54 @@ from script.analyse import lib_analyse # noqa: E402
TYPES_TEXTE = ("char", "text", "html") TYPES_TEXTE = ("char", "text", "html")
TYPES_NOMBRE = ("integer", "float", "monetary") TYPES_NOMBRE = ("integer", "float", "monetary")
# PostgreSQL borne ses entiers par TAILLE, et garder la largeur peut viser
# plus haut que la colonne : une valeur à 10 chiffres tire dans
# 10⁹..9 999 999 999, quand `integer` s'arrête à 2 147 483 647 et
# `smallint` à 32 767. Le dépassement lève, et l'écriture tenant en une
# transaction unique, il emporte TOUTE l'anonymisation.
#
# La bande se RÉTRÉCIT donc au plafond, au lieu d'écraser les tirages
# dessus : borner le tirage par `least` mettrait, sur une colonne
# `integer` à 10 chiffres, près de huit valeurs sur dix exactement à
# 2 147 483 647.
PLAFOND_ENTIER = {
"smallint": 32767,
"integer": 2147483647,
"bigint": 9223372036854775807,
}
# `atttypid::regtype::text` rend le nom canonique — `integer`, jamais
# `int4` — mais l'alias circule dans les dumps et les jeux d'essai.
ALIAS_ENTIER = {"int2": "smallint", "int4": "integer", "int8": "bigint"}
def type_entier(pg_type):
"""Le nom canonique du type entier de la colonne, ou None.
Rend None pour tout ce qui n'est pas un entier borné — `numeric`,
`double precision`, un texte — où aucun plafond ne s'applique.
"""
canon = ALIAS_ENTIER.get(pg_type, pg_type)
return canon if canon in PLAFOND_ENTIER else None
def type_de_coulee(champ):
"""Le type PostgreSQL vers lequel couler un tirage numérique.
Un `integer` d'Odoo n'implique PAS une colonne entière : une base
montée de version garde la colonne `numeric` qu'un champ `Float`
avait créée, `ir_model_fields` disant désormais `integer`. Y couler
en `integer` lève dès que la valeur dépasse 2 147 483 647 — et comme
le plafond d'`integer` bornait alors le haut d'une bande dont le bas
le dépassait déjà, la bande s'INVERSAIT et chaque ligne levait.
`numeric` n'a pas de plafond : un entier y reste entier par `floor`.
"""
canon = type_entier(champ.get("pg_type"))
if champ["ttype"] == "integer":
return canon or "numeric"
return canon
# Le plancher : aucune liste blanche ne le lève. # Le plancher : aucune liste blanche ne le lève.
PREFIXES_INTERDITS = ("ir.",) PREFIXES_INTERDITS = ("ir.",)
MODELES_INTERDITS = frozenset( MODELES_INTERDITS = frozenset(
@ -169,6 +217,16 @@ MOTS_PAR_DEFAUT = (
MODES = ("whitelist", "blacklist", "hybrid") MODES = ("whitelist", "blacklist", "hybrid")
# Les codes de sortie sont un CONTRAT : le menu les lit pour décider s'il
# tire une sauvegarde derrière l'anonymisation. Ils doivent se distinguer
# d'une TRACE PYTHON, qui sort en 1 — lire « tout ce qui n'est ni 0 ni 2 »
# comme du travail annoncé faisait demander la confirmation destructrice
# après un plantage, puis « appliquer » sur un plan jamais calculé.
SORTIE_RIEN = 0 # rien à anonymiser, ou écriture faite
SORTIE_REFUS = 2 # refus, base illisible, écriture en erreur
SORTIE_A_FAIRE = 3 # marche à blanc : du travail est annoncé
SORTIE_SANS_EFFET = 4 # `--apply` n'avait rien à écrire
def modele_interdit(modele): def modele_interdit(modele):
"""Le plancher, en une question.""" """Le plancher, en une question."""
@ -208,15 +266,43 @@ def champ_retenu(champ, inclure_connexion=False):
`champ` : dict avec model, name, ttype, pg_type, unique. `champ` : dict avec model, name, ttype, pg_type, unique.
""" """
return raison_du_refus(champ, inclure_connexion) is None
# Pourquoi une colonne n'est pas remplacée. Le rapport n'en NOMME qu'une —
# l'unicité numérique, la seule dont le silence laisserait partir une
# colonne identifiante — mais la raison se LIT ici et ne se redevine pas
# ailleurs : `id` est un entier unique, et c'est le plancher qui le
# refuse. Redevinée depuis le type et l'unicité, elle nommait `id` sur
# CHAQUE modèle, noyant les vrais avertissements ; et une colonne
# `xxx_id`, sous contrainte CHECK ou en jsonb numérique, se confondait de
# la même façon.
REFUS_PLANCHER = "plancher"
REFUS_CONNEXION = "connexion"
REFUS_RELATION = "relation"
REFUS_STRUCTURE = "structure"
REFUS_CONTRAINTE = "contrainte"
REFUS_JSONB_NOMBRE = "jsonb_nombre"
REFUS_NOMBRE_UNIQUE = "nombre_unique"
REFUS_TYPE = "type"
def raison_du_refus(champ, inclure_connexion=False):
"""Pourquoi ce champ n'est pas remplacé, ou None s'il l'est.
L'ORDRE des contrôles fait la réponse, et c'est la raison d'être de
cette fonction : plusieurs champs satisfont deux motifs à la fois, et
seul le PREMIER rencontré est celui qui les écarte.
"""
if champ["name"] in CHAMPS_INTERDITS: if champ["name"] in CHAMPS_INTERDITS:
return False return REFUS_PLANCHER
if champ["name"] in CHAMPS_CONNEXION and not inclure_connexion: if champ["name"] in CHAMPS_CONNEXION and not inclure_connexion:
return False return REFUS_CONNEXION
if champ["name"].endswith("_id") or champ["name"].endswith("_ids"): if champ["name"].endswith("_id") or champ["name"].endswith("_ids"):
# Une relation qui aurait échappé au filtre de ttype. # Une relation qui aurait échappé au filtre de ttype.
return False return REFUS_RELATION
if champ["name"] in CHAMPS_STRUCTURES: if champ["name"] in CHAMPS_STRUCTURES:
return False return REFUS_STRUCTURE
if champ.get("checked"): if champ.get("checked"):
# Une contrainte CHECK hors de portée. Mesuré, et la distinction # Une contrainte CHECK hors de portée. Mesuré, et la distinction
# compte : sur un NOMBRE toute contrainte borne la valeur — # compte : sur un NOMBRE toute contrainte borne la valeur —
@ -228,14 +314,43 @@ def champ_retenu(champ, inclure_connexion=False):
# important de la base intact — une anonymisation qui n'anonymisait # important de la base intact — une anonymisation qui n'anonymisait
# pas les noms. La requête ne lève donc ce drapeau, pour du texte, # pas les noms. La requête ne lève donc ce drapeau, pour du texte,
# que sur les contraintes de FORME. # que sur les contraintes de FORME.
return False return REFUS_CONTRAINTE
if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb": if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb":
# Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut # Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut
# vivre dans un jsonb par société. Y écrire un nombre nu ferait # vivre dans un jsonb par société. Y écrire un nombre nu ferait
# échouer l'UPDATE — et donc, transaction unique oblige, TOUTE # échouer l'UPDATE — et donc, transaction unique oblige, TOUTE
# l'anonymisation. On s'abstient plutôt que de deviner sa forme. # l'anonymisation. On s'abstient plutôt que de deviner sa forme.
return REFUS_JSONB_NOMBRE
if champ["ttype"] in TYPES_NOMBRE and champ.get("unique"):
# Un TIRAGE ne garantit rien : `expression_texte` colle l'id sur
# une colonne unique, un nombre n'a pas cette issue — y coller
# l'id changerait sa grandeur. Deux lignes au même nombre font
# échouer l'UPDATE, et transaction unique oblige, TOUTE
# l'anonymisation avec. Même parade que pour les CHECK et les
# jsonb numériques : on s'abstient, et le rapport le NOMME.
return REFUS_NOMBRE_UNIQUE
if champ["ttype"] in TYPES_TEXTE + TYPES_NOMBRE:
return None
return REFUS_TYPE
def abstention_a_nommer(champ, raison):
"""Cette colonne laissée en clair doit-elle être NOMMÉE au rapport ?
« Laquelle toucher » et « laquelle nommer » sont deux questions.
L'ordre des contrôles répond à la première : il rend UN motif, le
premier rencontré. Le réutiliser comme prédicat du rapport taisait
une colonne numérique unique dès qu'un autre motif la précédait —
une contrainte CHECK, un jsonb, un nom en `_id` — alors que c'est
exactement ce que la ligne ⚠ affirme, et exactement la situation pour
laquelle elle existe.
Le plancher, lui, reste muet : `id` est un entier unique sur CHAQUE
modèle, et le nommer partout noyait les vrais avertissements.
"""
if raison is None or raison == REFUS_PLANCHER:
return False return False
return champ["ttype"] in TYPES_TEXTE + TYPES_NOMBRE return champ["ttype"] in TYPES_NOMBRE and bool(champ.get("unique"))
def mots_pour(nom_champ, mots): def mots_pour(nom_champ, mots):
@ -309,6 +424,77 @@ def expression_texte(champ, mots):
return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END" return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END"
def expression_calibre(champ):
"""Le SQL qui remplace un nombre en gardant sa LARGEUR.
8839 tire dans 1000..9999. La décade se lit sur la valeur de chaque
LIGNE — `log(abs(col))` — et non sur la colonne : une colonne mêle des
largeurs, et c'est la largeur de la valeur que l'opérateur veut voir
survivre.
Trois cas que la règle ne couvre pas, et qui retombent sur l'étendue
mesurée :
- `NULL` reste `NULL`, comme partout ;
- `0` reste `0` : il n'a pas de largeur à garder, et un zéro devenu
743 fabrique de la donnée là où il n'y en avait pas ;
- `abs(col) < 1` n'a aucun chiffre avant la virgule. Appliquer la
règle y tirerait un taux entre 1 et 9, ce que l'étendue mesurée
existe précisément pour empêcher.
La partie ENTIÈRE décide, pour un `float` comme pour un `monetary` :
1234,56 garde ses quatre chiffres et ses deux décimales.
La bande est rétrécie au plafond du type qui ACCUEILLE le tirage
quand il en a un — voir `type_de_coulee` et `PLAFOND_ENTIER` : une
valeur à 10 chiffres coulée en `integer` dépasserait 2 147 483 647, et
l'écriture tenant en une transaction unique, elle emporterait tout.
"""
nom = ident(champ["name"])
entier = champ["ttype"] == "integer"
# La mesure passe par `numeric` : `abs()` sur le plus petit entier
# signé lève, sa valeur absolue ne tenant pas dans son propre type.
mesure = f"abs({nom}::numeric)"
# Le nombre de CHIFFRES, compté sur le texte de la partie entière.
# `floor(log(...))` s'en approchait, mais travaille en flottant et
# `log(999999999999999)` y vaut 15 tout rond : la décade gagnait un
# rang, et la copie un chiffre. En `numeric`, le compte est exact
# quelle que soit la grandeur.
chiffres = f"length(trunc({mesure})::text)"
decade = f"power(10::numeric, {chiffres} - 1)"
haut = f"{decade} * 10 - 1"
coulee = type_de_coulee(champ) or "numeric"
# La bande doit tenir dans le type qui ACCUEILLE le tirage, et il
# n'est pas toujours celui de la colonne — voir `type_de_coulee`.
# `numeric` n'a pas de plafond, donc rien à rétrécir.
if coulee in PLAFOND_ENTIER:
haut = f"least({haut}, {PLAFOND_ENTIER[coulee]})"
if entier:
# Le `+ 1` rend le haut de la bande atteignable : sans lui, 8839
# ne peut jamais sortir 9999. `random()::numeric` garde le calcul
# exact là où le flottant perd des rangs au-delà de 2^53.
tirage = (
f"(sign({nom}) * floor({decade}"
f" + random()::numeric * ({haut} - {decade} + 1)))::{coulee}"
)
else:
# Pas de `+ 1` ici : `round(…, 2)` d'un tirage qui touche le haut
# de la bande rendrait la décade SUIVANTE, soit un chiffre de plus.
tirage = (
f"round((sign({nom}) * ({decade}"
f" + random()::numeric * ({haut} - {decade})))::numeric, 2)"
)
# Sous l'unité et sur zéro, l'étendue mesurée reprend la main : c'est
# elle qui protège un taux, une heure ou une probabilité.
repli = expression_nombre(champ)
return (
f"CASE WHEN {nom} IS NULL THEN NULL"
f" WHEN {nom} = 0 THEN {nom}"
f" WHEN {mesure} < 1 THEN ({repli})"
f" ELSE {tirage} END"
)
def expression_nombre(champ): def expression_nombre(champ):
"""Le SQL qui remplace un nombre, DANS l'étendue de la colonne. """Le SQL qui remplace un nombre, DANS l'étendue de la colonne.
@ -331,9 +517,13 @@ def expression_nombre(champ):
nom = ident(champ["name"]) nom = ident(champ["name"])
bas, haut = champ.get("borne_min"), champ.get("borne_max") bas, haut = champ.get("borne_min"), champ.get("borne_max")
entier = champ["ttype"] == "integer" entier = champ["ttype"] == "integer"
# Même règle que pour le calibre : on coule vers le type qui accueille,
# et un `integer` d'Odoo peut vivre dans une colonne `numeric` dont
# l'étendue mesurée dépasse 2 147 483 647.
coulee = type_de_coulee(champ) or "numeric"
if bas is None or haut is None: if bas is None or haut is None:
tirage = ( tirage = (
"floor(random() * 1001)::integer" f"floor(random() * 1001)::{coulee}"
if entier if entier
else "round((random() * 1000)::numeric, 2)" else "round((random() * 1000)::numeric, 2)"
) )
@ -341,18 +531,27 @@ def expression_nombre(champ):
# +1 pour que la borne haute soit atteignable ; si bas == haut, # +1 pour que la borne haute soit atteignable ; si bas == haut,
# le tirage rend cette valeur, ce qui est sans risque : une # le tirage rend cette valeur, ce qui est sans risque : une
# colonne constante ne porte aucune information à masquer. # colonne constante ne porte aucune information à masquer.
tirage = f"floor({bas} + random() * ({haut} - {bas} + 1))::integer" tirage = f"floor({bas} + random() * ({haut} - {bas} + 1))::{coulee}"
else: else:
tirage = f"round(({bas} + random() * ({haut} - {bas}))::numeric, 2)" tirage = f"round(({bas} + random() * ({haut} - {bas}))::numeric, 2)"
return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END" return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END"
def sql_pour_table(table, champs, mots): def sql_pour_table(table, champs, mots, calibre=False):
"""Un seul UPDATE par table : toutes ses colonnes d'un coup.""" """Un seul UPDATE par table : toutes ses colonnes d'un coup.
`calibre` échange l'étendue mesurée contre la largeur de chaque
valeur. Les deux ne tiennent pas ensemble, et c'est l'opérateur qui
tranche : l'étendue protège les bornes que le CODE d'Odoo impose — une
heure de la journée, une probabilité — la largeur sert un export relu
à l'œil ou réimporté dans un champ borné.
"""
morceaux = [] morceaux = []
for champ in champs: for champ in champs:
if champ["ttype"] in TYPES_TEXTE: if champ["ttype"] in TYPES_TEXTE:
valeur = expression_texte(champ, mots) valeur = expression_texte(champ, mots)
elif calibre:
valeur = expression_calibre(champ)
else: else:
valeur = expression_nombre(champ) valeur = expression_nombre(champ)
morceaux.append(f"{ident(champ['name'])} = {valeur}") morceaux.append(f"{ident(champ['name'])} = {valeur}")
@ -448,6 +647,7 @@ def plan(
blacklist=(), blacklist=(),
inclure_connexion=False, inclure_connexion=False,
mots=None, mots=None,
calibre=False,
): ):
"""Ce qui sera écrit, table par table — avant d'écrire quoi que ce soit. """Ce qui sera écrit, table par table — avant d'écrire quoi que ce soit.
@ -459,20 +659,51 @@ def plan(
) )
retenus = set(modeles) retenus = set(modeles)
par_modele = {} par_modele = {}
# Ce qu'on ÉCARTE parce qu'aucune règle mécanique ne sait le
# remplacer : le rapport le nomme, sans quoi une colonne identifiante
# reste en clair sans que rien ne le dise.
abstenus = {}
for champ in champs: for champ in champs:
if champ["model"] not in retenus: if champ["model"] not in retenus:
continue continue
if not champ_retenu(champ, inclure_connexion): raison = raison_du_refus(champ, inclure_connexion)
if raison is not None:
if abstention_a_nommer(champ, raison):
abstenus.setdefault(champ["model"], []).append(champ["name"])
continue continue
par_modele.setdefault(champ["model"], []).append(champ) par_modele.setdefault(champ["model"], []).append(champ)
etapes = [] etapes = []
for modele in modeles: for modele in modeles:
laisses = sorted(abstenus.get(modele, ()))
liste = par_modele.get(modele) liste = par_modele.get(modele)
if not liste: sql = (
continue sql_pour_table(table_de(modele), liste, mots, calibre)
sql = sql_pour_table(table_de(modele), liste, mots) if liste
else None
)
if sql: if sql:
etapes.append({"model": modele, "fields": liste, "sql": sql}) etapes.append(
{
"model": modele,
"fields": liste,
"sql": sql,
"abstenus": laisses,
}
)
elif laisses:
# Une étape SANS travail, qui ne porte que l'avertissement.
# Un modèle dont la seule colonne anonymisable EST la
# numérique unique ne produit aucun UPDATE : l'écarter
# emportait l'avertissement, et le rapport affirmait « rien à
# anonymiser » sur la colonne même qu'il existe pour nommer.
etapes.append(
{
"model": modele,
"fields": [],
"sql": None,
"abstenus": laisses,
}
)
return etapes return etapes
@ -564,8 +795,13 @@ def nombre_valide(texte):
return True return True
def appliquer_sondes(etapes, ecartees, bornes, mots): def appliquer_sondes(etapes, ecartees, bornes, mots, calibre=False):
"""Refaire le plan sans les écartées, et avec les bornes mesurées.""" """Refaire le plan sans les écartées, et avec les bornes mesurées.
`calibre` est repassé tel quel : refaire le SQL sans lui rendrait la
marche à blanc et l'écriture différentes, ce qui est exactement ce que
le rendu séparé existe pour empêcher.
"""
propre = [] propre = []
for etape in etapes: for etape in etapes:
exclues = set(ecartees.get(etape["model"], ())) exclues = set(ecartees.get(etape["model"], ()))
@ -583,9 +819,14 @@ def appliquer_sondes(etapes, ecartees, bornes, mots):
# Pas de garde sur une liste vide : `sql_pour_table` rend None, et # Pas de garde sur une liste vide : `sql_pour_table` rend None, et
# le `if sql` ci-dessous l'écarte. Deux vérifications pour la même # le `if sql` ci-dessous l'écarte. Deux vérifications pour la même
# chose se contredisent un jour. # chose se contredisent un jour.
sql = sql_pour_table(table_de(etape["model"]), gardes, mots) sql = sql_pour_table(table_de(etape["model"]), gardes, mots, calibre)
if sql: if sql:
propre.append({**etape, "fields": gardes, "sql": sql}) propre.append({**etape, "fields": gardes, "sql": sql})
elif etape.get("abstenus"):
# L'étape perd sa dernière colonne à la sonde, mais porte
# encore l'avertissement : le garder, faute de quoi il
# disparaît ici comme il disparaissait du plan.
propre.append({**etape, "fields": [], "sql": None})
return propre return propre
@ -593,13 +834,28 @@ def render(etapes, applique=False, verbeux=False):
"""Le rapport. Il dit ce qui est ÉCARTÉ autant que ce qui est pris.""" """Le rapport. Il dit ce qui est ÉCARTÉ autant que ce qui est pris."""
if not etapes: if not etapes:
return f"✅ {t('Nothing to anonymise with these lists.')}" return f"✅ {t('Nothing to anonymise with these lists.')}"
total = sum(len(e["fields"]) for e in etapes) # Une étape sans SQL ne porte QU'un avertissement : elle ne compte pas
# pour du travail, et se dit quand même. C'est le seul cas où la
# colonne nommée est aussi la seule qu'il y avait à traiter.
travail = [e for e in etapes if e.get("sql")]
total = sum(len(e["fields"]) for e in travail)
tete = ( tete = (
f"🎭 {len(etapes)} {t('model(s)')}, {total} {t('column(s)')}" (
f" — {t('written') if applique else t('dry run, nothing written')}" f"🎭 {len(travail)} {t('model(s)')}, {total} {t('column(s)')}"
f" — {t('written') if applique else t('dry run, nothing written')}"
)
if travail
else f"✅ {t('Nothing to anonymise with these lists.')}"
) )
lignes = [tete, ""] lignes = [tete, ""]
for etape in etapes: for etape in etapes:
if not etape.get("sql"):
lignes.append(f" {etape['model']}")
lignes.append(
f" ⚠ {t('left in clear, numeric and unique:')}"
f" {', '.join(etape.get('abstenus') or ())}"
)
continue
textes = [f for f in etape["fields"] if f["ttype"] in TYPES_TEXTE] textes = [f for f in etape["fields"] if f["ttype"] in TYPES_TEXTE]
nombres = [f for f in etape["fields"] if f["ttype"] in TYPES_NOMBRE] nombres = [f for f in etape["fields"] if f["ttype"] in TYPES_NOMBRE]
traduits = [f for f in textes if f["pg_type"] == "jsonb"] traduits = [f for f in textes if f["pg_type"] == "jsonb"]
@ -610,13 +866,23 @@ def render(etapes, applique=False, verbeux=False):
if uniques: if uniques:
detail += f", {len(uniques)} {t('unique')}" detail += f", {len(uniques)} {t('unique')}"
lignes.append(f" {etape['model']:<34} {detail}") lignes.append(f" {etape['model']:<34} {detail}")
abstenus = etape.get("abstenus") or []
if abstenus:
# Une colonne numérique UNIQUE reste en clair : aucun tirage
# ne garantit son unicité, et y coller l'id changerait sa
# grandeur. Le taire laisserait une colonne identifiante
# partir sans que rien ne le dise.
lignes.append(
f" ⚠ {t('left in clear, numeric and unique:')}"
f" {', '.join(abstenus)}"
)
if verbeux: if verbeux:
for champ in etape["fields"]: for champ in etape["fields"]:
lignes.append( lignes.append(
f" {champ['name']:<30} {champ['ttype']}" f" {champ['name']:<30} {champ['ttype']}"
f" / {champ['pg_type']}" f" / {champ['pg_type']}"
) )
if not applique: if travail and not applique:
lignes.append("") lignes.append("")
lignes.append(f" {t('Use --apply --confirm <database> to write.')}") lignes.append(f" {t('Use --apply --confirm <database> to write.')}")
return "\n".join(lignes) return "\n".join(lignes)
@ -656,7 +922,8 @@ def ecrire(database, etapes, config_path=None, timeout=900):
env = lib_analyse.pg_env(config_path, timeout=timeout) env = lib_analyse.pg_env(config_path, timeout=timeout)
env["PGOPTIONS"] = f"-c statement_timeout={timeout}s" env["PGOPTIONS"] = f"-c statement_timeout={timeout}s"
sql = "\n".join(etape["sql"] for etape in etapes) # Une étape sans SQL ne porte qu'un avertissement pour le rapport.
sql = "\n".join(e["sql"] for e in etapes if e.get("sql"))
# PAR FICHIER, jamais par `-c`. Linux plafonne un seul argument à # PAR FICHIER, jamais par `-c`. Linux plafonne un seul argument à
# MAX_ARG_STRLEN — 32 pages, soit 131 072 octets. Mesuré sur une base # MAX_ARG_STRLEN — 32 pages, soit 131 072 octets. Mesuré sur une base
@ -725,6 +992,11 @@ def main(argv=None):
) )
parser.add_argument("--words", help=t("python file declaring MOTS")) parser.add_argument("--words", help=t("python file declaring MOTS"))
parser.add_argument("--include-logins", action="store_true") parser.add_argument("--include-logins", action="store_true")
parser.add_argument(
"--keep-digits",
action="store_true",
help=t("draw a number of the same width; drops the extent"),
)
parser.add_argument("--apply", action="store_true") parser.add_argument("--apply", action="store_true")
parser.add_argument( parser.add_argument(
"--confirm", "--confirm",
@ -766,11 +1038,12 @@ def main(argv=None):
[m.strip() for m in args.exclude.split(",") if m.strip()], [m.strip() for m in args.exclude.split(",") if m.strip()],
args.include_logins, args.include_logins,
mots, mots,
args.keep_digits,
) )
# La sonde AVANT le rendu : la marche à blanc doit montrer ce que # La sonde AVANT le rendu : la marche à blanc doit montrer ce que
# `--apply` ferait, pas une approximation plus large. # `--apply` ferait, pas une approximation plus large.
ecartees, bornes = sonder_colonnes(args.database, etapes, args.config) ecartees, bornes = sonder_colonnes(args.database, etapes, args.config)
etapes = appliquer_sondes(etapes, ecartees, bornes, mots) etapes = appliquer_sondes(etapes, ecartees, bornes, mots, args.keep_digits)
if ecartees: if ecartees:
combien = sum(len(v) for v in ecartees.values()) combien = sum(len(v) for v in ecartees.values())
print( print(
@ -781,16 +1054,27 @@ def main(argv=None):
print(f" {modele} : {', '.join(sorted(ecartees[modele]))}") print(f" {modele} : {', '.join(sorted(ecartees[modele]))}")
print() print()
# Une étape sans SQL ne porte qu'un avertissement : la compter pour du
# travail ferait confirmer une écriture qui n'aurait pas lieu.
travail = any(e.get("sql") for e in etapes)
if not args.apply: if not args.apply:
print(render(etapes, applique=False, verbeux=args.verbose)) print(render(etapes, applique=False, verbeux=args.verbose))
return 1 if etapes else 0 return SORTIE_A_FAIRE if travail else SORTIE_RIEN
if not travail:
# Sans cette sortie, `ecrire` remettait un script VIDE à psql, qui
# rend 0 : l'appelant lisait « écriture faite » et tirait une
# sauvegarde de la base intacte en l'annonçant anonymisée.
print(render(etapes, applique=False, verbeux=args.verbose))
print(f"↩️ {t('Nothing was written:')} {t('nothing to do.')}")
return SORTIE_SANS_EFFET
erreur = ecrire(args.database, etapes, args.config) erreur = ecrire(args.database, etapes, args.config)
if erreur: if erreur:
print(f"❌ {t('Nothing was written:')} {erreur}", file=sys.stderr) print(f"❌ {t('Nothing was written:')} {erreur}", file=sys.stderr)
return 2 return SORTIE_REFUS
print(render(etapes, applique=True, verbeux=args.verbose)) print(render(etapes, applique=True, verbeux=args.verbose))
return 0 return SORTIE_RIEN
if __name__ == "__main__": if __name__ == "__main__":

1923
script/data/external_file.py Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,7 @@ from script.todo.qemu_menu import QemuMenuMixin
from script.todo.qemu_network import QemuNetworkMixin from script.todo.qemu_network import QemuNetworkMixin
from script.todo.qemu_recover import QemuRecoverMixin from script.todo.qemu_recover import QemuRecoverMixin
from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t
from script.todo.transform_menu import TransformMenuMixin
from script.todo.version_manager import get_odoo_version from script.todo.version_manager import get_odoo_version
from script.todo.vpn_menu import VpnMenuMixin from script.todo.vpn_menu import VpnMenuMixin
@ -104,6 +105,7 @@ class TODO(
QemuAccessMixin, QemuAccessMixin,
ProxmoxMenuMixin, ProxmoxMenuMixin,
LongTestMenuMixin, LongTestMenuMixin,
TransformMenuMixin,
VpnMenuMixin, VpnMenuMixin,
AssistantMenuMixin, AssistantMenuMixin,
): ):
@ -235,22 +237,23 @@ class TODO(
── {t("Data")} ── ── {t("Data")} ──
[6] {t("Database - Database tools")} [6] {t("Database - Database tools")}
[7] {t("Analyse - Odoo database analysis")} [7] {t("Analyse - Odoo database analysis")}
[8] {t("Transform data - Transform your data")}
── {t("Sources & documentation")} ── ── {t("Sources & documentation")} ──
[8] {t("Git - Git and shell tools")} [9] {t("Git - Git and shell tools")}
[9] {t("Doc - Documentation search")} [10] {t("Doc - Documentation search")}
── {t("AI & automation")} ── ── {t("AI & automation")} ──
[10] {t("GPT code - AI assistant tools")} [11] {t("GPT code - AI assistant tools")}
[11] {t("Automation - Demonstration of developed features")} [12] {t("Automation - Demonstration of developed features")}
── {t("Deployment, network & security")} ── ── {t("Deployment, network & security")} ──
[12] {t("Deploy - Deploy ERPLibre locally")} [13] {t("Deploy - Deploy ERPLibre locally")}
[13] {t("Network - Network tools")} [14] {t("Network - Network tools")}
[14] {t("Security - Dependency security audit")} [15] {t("Security - Dependency security audit")}
── {t("Preferences")} ── ── {t("Preferences")} ──
[15] {t("Language - Change language / Changer la langue")} [16] {t("Language - Change language / Changer la langue")}
[0] {t("Back")} [0] {t("Back")}
""" """
while True: while True:
@ -287,34 +290,38 @@ class TODO(
if status is not False: if status is not False:
return return
elif status == "8": elif status == "8":
status = self.prompt_execute_git() status = self.prompt_execute_transform()
if status is not False: if status is not False:
return return
elif status == "9": elif status == "9":
status = self.prompt_execute_doc() status = self.prompt_execute_git()
if status is not False: if status is not False:
return return
elif status == "10": elif status == "10":
status = self.prompt_execute_gpt_code() status = self.prompt_execute_doc()
if status is not False: if status is not False:
return return
elif status == "11": elif status == "11":
status = self.prompt_execute_function() status = self.prompt_execute_gpt_code()
if status is not False: if status is not False:
return return
elif status == "12": elif status == "12":
status = self.prompt_execute_deploy() status = self.prompt_execute_function()
if status is not False: if status is not False:
return return
elif status == "13": elif status == "13":
status = self.prompt_execute_network() status = self.prompt_execute_deploy()
if status is not False: if status is not False:
return return
elif status == "14": elif status == "14":
status = self.prompt_execute_security() status = self.prompt_execute_network()
if status is not False: if status is not False:
return return
elif status == "15": elif status == "15":
status = self.prompt_execute_security()
if status is not False:
return
elif status == "16":
status = self._change_language() status = self._change_language()
if status is not False: if status is not False:
return return
@ -584,6 +591,7 @@ class TODO(
"prompt_execute_config": "Config", "prompt_execute_config": "Config",
"prompt_execute_database": "Database", "prompt_execute_database": "Database",
"prompt_execute_analyse": "Analyse", "prompt_execute_analyse": "Analyse",
"prompt_execute_transform": "Transform data",
"prompt_execute_doc": "Doc", "prompt_execute_doc": "Doc",
"prompt_execute_git": "Git", "prompt_execute_git": "Git",
"prompt_execute_git_local_server": "Git local server", "prompt_execute_git_local_server": "Git local server",
@ -4453,15 +4461,38 @@ class TODO(
La confirmation redemande le NOM de la base. Une frappe sur « o » La confirmation redemande le NOM de la base. Une frappe sur « o »
se tape par réflexe ; recopier un nom long oblige à regarder ce se tape par réflexe ; recopier un nom long oblige à regarder ce
qu'on détruit. qu'on détruit.
Rend VRAI seulement si l'écriture a eu lieu. L'appelant qui tire
une sauvegarde derrière en a besoin : sans distinction, un
renoncement produisait un zip de la base NON anonymisée, annoncé
comme le résultat d'une anonymisation.
Les codes de sortie de la marche à blanc sont un CONTRAT, et le
travail s'annonce par un code À LUI — voir `anonymize.SORTIE_*`.
Lire « tout ce qui n'est ni 0 ni 2 » comme du travail faisait
demander la confirmation destructrice après une trace Python,
laquelle sort en 1, puis « appliquer » un plan jamais calculé.
""" """
from script.analyse import monitoring from script.analyse import anonymize, monitoring
choix = self._monitoring_anonymize_options() choix = self._monitoring_anonymize_options()
if choix is None: if choix is None:
return return False
print() print()
if monitoring.run_analysis(analyse, database, extra=choix) == 2: code = monitoring.run_analysis(analyse, database, extra=choix)
return if code != anonymize.SORTIE_A_FAIRE:
if code == anonymize.SORTIE_RIEN:
# Demander de retaper le nom d'une base qu'on ne touchera
# pas obtient un consentement sans objet, et l'appelant
# tirait ensuite une sauvegarde de la base INTACTE en
# l'annonçant anonymisée.
print(f"↩️ {t('Nothing to anonymise: nothing to confirm.')}")
elif code != anonymize.SORTIE_REFUS:
print(
f"❌ {t('The dry run ended on an unexpected code:')}"
f" {code}"
)
return False
print() print()
print( print(
f"⚠️ {t('This DESTROYS the data of')} '{database}'" f"⚠️ {t('This DESTROYS the data of')} '{database}'"
@ -4472,9 +4503,17 @@ class TODO(
).strip() ).strip()
if tape != database: if tape != database:
print(f"↩️ {t('Cancelled: nothing was written.')}") print(f"↩️ {t('Cancelled: nothing was written.')}")
return return False
monitoring.run_analysis( # SEUL le code de succès vaut « l'écriture a eu lieu » : un plan
analyse, database, extra=choix + ["--apply", "--confirm", database] # devenu vide entre les deux passes rend SORTIE_SANS_EFFET, et non
# le 0 que psql donnait sur un script vide.
return (
monitoring.run_analysis(
analyse,
database,
extra=choix + ["--apply", "--confirm", database],
)
== anonymize.SORTIE_RIEN
) )
def _monitoring_anonymize_options(self): def _monitoring_anonymize_options(self):
@ -4501,6 +4540,18 @@ class TODO(
elif mode == "whitelist": elif mode == "whitelist":
print(f"❌ {t('A whitelist with no model would do nothing.')}") print(f"❌ {t('A whitelist with no model would do nothing.')}")
return None return None
# Les logins RESTENT par défaut : on anonymise pour pouvoir
# partager une copie UTILISABLE, et tout randomiser empêcherait
# quiconque de s'y connecter.
if self._is_yes(input(f"💬 {t('Anonymise the logins too? (y/N): ')}")):
extra.append("--include-logins")
# Le calibre échange une garantie contre une autre : l'étendue
# mesurée protège les bornes que le CODE d'Odoo impose — une heure
# de la journée, une probabilité — et la largeur sert un export
# relu à l'œil. L'invite le dit avant de demander.
print(f" {t('Keeping the digit count drops the measured extent.')}")
if self._is_yes(input(f"💬 {t('Keep the digit count? (y/N): ')}")):
extra.append("--keep-digits")
mots = input( mots = input(
f"💬 {t('Python file declaring MOTS (empty for the built-in): ')}" f"💬 {t('Python file declaring MOTS (empty for the built-in): ')}"
).strip() ).strip()
@ -4641,8 +4692,16 @@ class TODO(
if neutralise != "n": if neutralise != "n":
more_arg = "--neutralize " more_arg = "--neutralize "
database += "_neutralize" database += "_neutralize"
else:
# Refuser est un choix légitime — reproduire un bug de cron
# sur une copie en est un — mais il faut savoir ce que la
# copie pourra faire depuis cette machine.
print(
f"⚠ {t('Not neutralized: this copy can send mail and run')}"
f" {t('its crons from this machine.')}"
)
status, _ = self._execute.exec_command_live( status, _ = self.execute.exec_command_live(
f"python3 ./script/database/db_restore.py -d {database} " f"python3 ./script/database/db_restore.py -d {database} "
f"{more_arg}--ignore_cache --image {image}", f"{more_arg}--ignore_cache --image {image}",
return_status_and_output=True, return_status_and_output=True,
@ -4653,7 +4712,7 @@ class TODO(
print(f"❌ {t('The restore failed.')}") print(f"❌ {t('The restore failed.')}")
return None return None
if more_arg: if more_arg:
status, _ = self._execute.exec_command_live( status, _ = self.execute.exec_command_live(
f"./script/addons/update_prod_to_dev.sh {database}", f"./script/addons/update_prod_to_dev.sh {database}",
return_status_and_output=True, return_status_and_output=True,
single_source_erplibre=True, single_source_erplibre=True,

View file

@ -11410,6 +11410,10 @@ TRANSLATIONS = {
"fr": "Annulé : rien n'a été écrit.", "fr": "Annulé : rien n'a été écrit.",
"en": "Cancelled: nothing was written.", "en": "Cancelled: nothing was written.",
}, },
"Nothing to anonymise: nothing to confirm.": {
"fr": "Rien à anonymiser : rien à confirmer.",
"en": "Nothing to anonymise: nothing to confirm.",
},
"Hybrid: the default personal-data models, adjusted": { "Hybrid: the default personal-data models, adjusted": {
"fr": "Hybride : les modèles à données personnelles par défaut, ajustés", "fr": "Hybride : les modèles à données personnelles par défaut, ajustés",
"en": "Hybrid: the default personal-data models, adjusted", "en": "Hybrid: the default personal-data models, adjusted",
@ -11498,6 +11502,14 @@ TRANSLATIONS = {
"fr": "Rien n'a été écrit :", "fr": "Rien n'a été écrit :",
"en": "Nothing was written:", "en": "Nothing was written:",
}, },
"nothing to do.": {
"fr": "rien à faire.",
"en": "nothing to do.",
},
"The dry run ended on an unexpected code:": {
"fr": "La marche à blanc s'est terminée sur un code inattendu :",
"en": "The dry run ended on an unexpected code:",
},
"This file declares no MOTS:": { "This file declares no MOTS:": {
"fr": "Ce fichier ne déclare aucun MOTS :", "fr": "Ce fichier ne déclare aucun MOTS :",
"en": "This file declares no MOTS:", "en": "This file declares no MOTS:",
@ -12831,6 +12843,764 @@ TRANSLATIONS = {
# « models », « still waiting for » et « Type an address » servent aussi # « models », « still waiting for » et « Type an address » servent aussi
# ici et sont définies plus haut : les redéfinir écraserait la première # ici et sont définies plus haut : les redéfinir écraserait la première
# sans rien lever. # sans rien lever.
# Transform data - fichiers externes
"Transform data - Transform your data": {
"fr": "🪄 Transform data - Transformer vos données",
"en": "🪄 Transform data - Transform your data",
},
"Source file": {
"fr": "📁 Fichier source",
"en": "📁 Source file",
},
"Transform your data: read, describe, then copy.": {
"fr": "Transformer vos données : lire, décrire, puis en tirer une copie.",
"en": "Transform your data: read, describe, then copy.",
},
"Source": {
"fr": "📁 Source",
"en": "📁 Source",
},
"Anonymise an Odoo database or a backup": {
"fr": "🎭 Anonymiser une base Odoo ou une sauvegarde",
"en": "🎭 Anonymise an Odoo database or a backup",
},
"The database ITSELF is modified; no copy.": {
"fr": "La base ELLE-MÊME est modifiée ; aucune copie.",
"en": "The database ITSELF is modified; no copy.",
},
"Nothing was written; no backup was drawn.": {
"fr": "Rien n'a été écrit ; aucune sauvegarde n'a été tirée.",
"en": "Nothing was written; no backup was drawn.",
},
"Backup name (Enter to accept): ": {
"fr": "Nom de la sauvegarde (Entrée pour accepter) : ",
"en": "Backup name (Enter to accept): ",
},
"Backup written: ": {
"fr": "Sauvegarde écrite : ",
"en": "Backup written: ",
},
"Database kept: ": {
"fr": "Base conservée : ",
"en": "Database kept: ",
},
"Gone already, only in the register:": {
"fr": "Déjà disparue(s), seulement au registre :",
"en": "Gone already, only in the register:",
},
"Use Database to drop one.": {
"fr": "Passer par Database pour en détruire une.",
"en": "Use Database to drop one.",
},
"Advanced options? (y/N): ": {
"fr": "Options avancées ? (o/N) : ",
"en": "Advanced options? (y/N): ",
},
"every sheet": {
"fr": "toutes les feuilles",
"en": "every sheet",
},
"no column left untouched": {
"fr": "aucune colonne laissée intacte",
"en": "no column left untouched",
},
"column(s) left untouched, chosen on screen": {
"fr": "colonne(s) laissée(s) intacte(s), choisies à l'écran",
"en": "column(s) left untouched, chosen on screen",
},
"numbers replaced, widths kept": {
"fr": "nombres remplacés, largeurs gardées",
"en": "numbers replaced, widths kept",
},
"numbers replaced within the measured extent": {
"fr": "nombres remplacés dans l'étendue mesurée",
"en": "numbers replaced within the measured extent",
},
"text replaced": {
"fr": "texte remplacé",
"en": "text replaced",
},
"the header row is left readable": {
"fr": "la ligne d'en-tête reste lisible",
"en": "the header row is left readable",
},
"sheet(s) with no header row on screen": {
"fr": "feuille(s) sans en-tête à l'écran : leur 1re ligne est de la donnée",
"en": "sheet(s) with no header row on screen: their 1st row is data",
},
"no seed: not reproducible": {
"fr": "aucune graine : non reproductible",
"en": "no seed: not reproducible",
},
"Taken as given:": {
"fr": "Pris tel quel :",
"en": "Taken as given:",
},
"Anonymise the logins too? (y/N): ": {
"fr": "Anonymiser aussi les identifiants de connexion ? (o/N) : ",
"en": "Anonymise the logins too? (y/N): ",
},
"Keeping the digit count drops the measured extent.": {
"fr": "Garder le nombre de chiffres abandonne l'étendue mesurée.",
"en": "Keeping the digit count drops the measured extent.",
},
"Not neutralized: this copy can send mail and run": {
"fr": "Non neutralisée : cette copie peut envoyer du courriel et faire tourner",
"en": "Not neutralized: this copy can send mail and run",
},
"its crons from this machine.": {
"fr": "ses crons depuis cette machine.",
"en": "its crons from this machine.",
},
"Databases produced": {
"fr": "🗄 Bases produites",
"en": "🗄 Databases produced",
},
"External files: read, describe, then draw a copy.": {
"fr": "Fichiers externes : lire, décrire, puis en tirer une copie.",
"en": "External files: read, describe, then draw a copy.",
},
"Environment": {
"fr": "🧰 Environnement",
"en": "🧰 Environment",
},
"Open a file and read its report": {
"fr": "📖 Ouvrir un fichier et lire son rapport",
"en": "📖 Open a file and read its report",
},
"Install the reading environment": {
"fr": "📦 Installer l'environnement de lecture",
"en": "📦 Install the reading environment",
},
"What can this machine read?": {
"fr": "🩺 Que sait lire cette machine ?",
"en": "🩺 What can this machine read?",
},
"Copies produced": {
"fr": "🗂 Copies produites",
"en": "🗂 Copies produced",
},
"A dedicated environment is required to read this format.": {
"fr": "Un environnement dédié est nécessaire pour lire ce format.",
"en": "A dedicated environment is required to read this format.",
},
"Create it now? (Y/n): ": {
"fr": "Le créer maintenant ? (O/n) : ",
"en": "Create it now? (Y/n): ",
},
"The environment is ready.": {
"fr": "L'environnement est prêt.",
"en": "The environment is ready.",
},
"Creation finished but the libraries are still missing.": {
"fr": "Création terminée mais les bibliothèques manquent toujours.",
"en": "Creation finished but the libraries are still missing.",
},
"python -m venv exited with": {
"fr": "python -m venv a rendu",
"en": "python -m venv exited with",
},
"Access files need a system package.": {
"fr": "Les fichiers Access demandent un paquet système.",
"en": "Access files need a system package.",
},
"readable": {
"fr": "lisible",
"en": "readable",
},
"not readable": {
"fr": "non lisible",
"en": "not readable",
},
"Contents do not match the extension: read as ": {
"fr": "Le contenu ne correspond pas à l'extension : lu comme ",
"en": "Contents do not match the extension: read as ",
},
"Encoding detected by ": {
"fr": "Encodage détecté par ",
"en": "Encoding detected by ",
},
"Delimiter detected by ": {
"fr": "Délimiteur détecté par ",
"en": "Delimiter detected by ",
},
"bom": {
"fr": "BOM",
"en": "BOM",
},
"chardet": {
"fr": "chardet",
"en": "chardet",
},
"sniffer": {
"fr": "sniffer",
"en": "sniffer",
},
"mesure": {
"fr": "mesure",
"en": "measurement",
},
"repli": {
"fr": "repli",
"en": "fallback",
},
"sheet(s)": {
"fr": "feuille(s)",
"en": "sheet(s)",
},
"chart sheet(s)": {
"fr": "feuille(s) graphique",
"en": "chart sheet(s)",
},
"hidden": {
"fr": "masquée",
"en": "hidden",
},
"formula(s)": {
"fr": "formule(s)",
"en": "formula(s)",
},
"formula(s) holding a text literal": {
"fr": "formule(s) portant un littéral texte",
"en": "formula(s) holding a text literal",
},
"distinct value(s)": {
"fr": "valeur(s) distincte(s)",
"en": "distinct value(s)",
},
"VBA macros present": {
"fr": "Macros VBA présentes",
"en": "VBA macros present",
},
"No VBA macro": {
"fr": "Aucune macro VBA",
"en": "No VBA macro",
},
"pivot table(s)": {
"fr": "tableau(x) croisé(s)",
"en": "pivot table(s)",
},
"chart(s) with cached series": {
"fr": "graphique(s) à séries en cache",
"en": "chart(s) with cached series",
},
"image(s) and drawing(s)": {
"fr": "image(s) et dessin(s)",
"en": "image(s) and drawing(s)",
},
"external link(s)": {
"fr": "lien(s) externe(s)",
"en": "external link(s)",
},
"cell hyperlink(s)": {
"fr": "hyperlien(s) de cellule",
"en": "cell hyperlink(s)",
},
"comment(s)": {
"fr": "commentaire(s)",
"en": "comment(s)",
},
"comment author(s)": {
"fr": "auteur(s) de commentaire",
"en": "comment author(s)",
},
"header(s)/footer(s)": {
"fr": "en-tête(s)/pied(s)",
"en": "header(s)/footer(s)",
},
"validation(s)": {
"fr": "validation(s)",
"en": "validation(s)",
},
"conditional format(s)": {
"fr": "mise(s) en forme conditionnelle(s)",
"en": "conditional format(s)",
},
"Text outside cells": {
"fr": "Texte hors cellules",
"en": "Text outside cells",
},
"Author": {
"fr": "Auteur",
"en": "Author",
},
"Link targets": {
"fr": "Cibles des liens",
"en": "Link targets",
},
"named range(s)": {
"fr": "plage(s) nommée(s)",
"en": "named range(s)",
},
"Anonymisable": {
"fr": "Anonymisable",
"en": "Anonymisable",
},
"word(s) in the pool": {
"fr": "mot(s) dans le vivier",
"en": "word(s) in the pool",
},
"Left intact": {
"fr": "Laissé intact",
"en": "Left intact",
},
"date(s)": {
"fr": "date(s)",
"en": "date(s)",
},
"boolean(s)": {
"fr": "booléen(s)",
"en": "boolean(s)",
},
"error value(s)": {
"fr": "valeur(s) d'erreur",
"en": "error value(s)",
},
"binary value(s)": {
"fr": "valeur(s) binaire(s)",
"en": "binary value(s)",
},
"This format stores no formula readable here.": {
"fr": "Ce format ne stocke aucune formule lisible ici.",
"en": "This format stores no formula readable here.",
},
"saved query(ies)": {
"fr": "requête(s) enregistrée(s)",
"en": "saved query(ies)",
},
"Access saved queries are readable here.": {
"fr": "Les requêtes enregistrées d'Access sont lisibles ici.",
"en": "Access saved queries are readable here.",
},
"Install it by hand to read Access saved queries.": {
"fr": "L'installer à la main pour lire les requêtes enregistrées d'Access.",
"en": "Install it by hand to read Access saved queries.",
},
"Saved queries cannot be counted here: install mdbtools to know whether this file holds any.": {
"fr": "Les requêtes enregistrées ne peuvent pas être comptées ici : installer mdbtools pour savoir si ce fichier en porte.",
"en": "Saved queries cannot be counted here: install mdbtools to know whether this file holds any.",
},
"Saved queries are not carried over to the copy.": {
"fr": "Les requêtes enregistrées ne sont pas reprises dans la copie.",
"en": "Saved queries are not carried over to the copy.",
},
"Saved queries are not readable in pure Python.": {
"fr": "Les requêtes enregistrées ne sont pas lisibles en pur Python.",
"en": "Saved queries are not readable in pure Python.",
},
"Only 20 fallback words are available: randomwordfr is missing.": {
"fr": "Seuls 20 mots de repli sont disponibles : randomwordfr est absent.",
"en": "Only 20 fallback words are available: randomwordfr is missing.",
},
"0 to cancel": {
"fr": "0 pour annuler",
"en": "0 to cancel",
},
"Anonymise this file? (y/N): ": {
"fr": "Anonymiser ce fichier ? (o/N) : ",
"en": "Anonymise this file? (y/N): ",
},
"Convert at the same time? (xlsx/csv/json/xml, empty = keep): ": {
"fr": "Convertir en même temps ? (xlsx/csv/json/xml, vide = garder) : ",
"en": "Convert at the same time? (xlsx/csv/json/xml, empty = keep): ",
},
"Sheets to process (empty = all): ": {
"fr": "Feuilles à traiter (vide = toutes) : ",
"en": "Sheets to process (empty = all): ",
},
"Columns to leave untouched (empty = none): ": {
"fr": "Colonnes à laisser intactes (vide = aucune) : ",
"en": "Columns to leave untouched (empty = none): ",
},
"Numbers keep their digit count, so the column's measured extent no longer bounds them: a rate or a year can leave its range.": {
"fr": "Les nombres gardent leur nombre de chiffres, donc l'étendue mesurée de la colonne ne les borne plus : un taux ou une année peut sortir de sa plage.",
"en": "Numbers keep their digit count, so the column's measured extent no longer bounds them: a rate or a year can leave its range.",
},
"draw a number of the same width; drops the extent": {
"fr": "tirer un nombre de même largeur ; abandonne l'étendue",
"en": "draw a number of the same width; drops the extent",
},
"left in clear, numeric and unique:": {
"fr": "laissée(s) en clair, numérique(s) et unique(s) :",
"en": "left in clear, numeric and unique:",
},
"Keep the digit count? (y/N): ": {
"fr": "Garder le nombre de chiffres ? (o/N) : ",
"en": "Keep the digit count? (y/N): ",
},
"Replace numbers? (Y/n): ": {
"fr": "Remplacer les nombres ? (O/n) : ",
"en": "Replace numbers? (Y/n): ",
},
"Replace text? (Y/n): ": {
"fr": "Remplacer le texte ? (O/n) : ",
"en": "Replace text? (Y/n): ",
},
"Anonymise the header row too? (y/N): ": {
"fr": "Anonymiser aussi la ligne d'en-tête ? (o/N) : ",
"en": "Anonymise the header row too? (y/N): ",
},
"Random seed (empty = not reproducible): ": {
"fr": "Graine aléatoire (vide = non reproductible) : ",
"en": "Random seed (empty = not reproducible): ",
},
"Mapping table to reuse (empty = a new one): ": {
"fr": "Table de correspondance à réutiliser (vide = nouvelle) : ",
"en": "Mapping table to reuse (empty = a new one): ",
},
"Keep the VBA macros in the copy? (y/N): ": {
"fr": "Garder les macros VBA dans la copie ? (o/N) : ",
"en": "Keep the VBA macros in the copy? (y/N): ",
},
"Keep the charts in the copy? (y/N): ": {
"fr": "Garder les graphiques dans la copie ? (o/N) : ",
"en": "Keep the charts in the copy? (y/N): ",
},
"Macros are kept, so the copy is named .xlsm: Excel refuses a .xlsx holding a VBA project.": {
"fr": "Les macros sont gardées, donc la copie se nomme .xlsm : Excel refuse un .xlsx qui porte un projet VBA.",
"en": "Macros are kept, so the copy is named .xlsm: Excel refuses a .xlsx holding a VBA project.",
},
"The copy is written in UTF-8, whatever the source was.": {
"fr": "La copie est écrite en UTF-8, quel qu'ait été l'encodage de la source.",
"en": "The copy is written in UTF-8, whatever the source was.",
},
"The kept VBA project quotes a source value; answer no to the macro question to write the copy.": {
"fr": "Le projet VBA gardé cite une valeur de la source ; répondre non à la question des macros pour écrire la copie.",
"en": "The kept VBA project quotes a source value; answer no to the macro question to write the copy.",
},
"An integer column is saturated: the copy holds the same set of values, only reshuffled.": {
"fr": "Une colonne d'entiers est saturée : la copie porte le même ensemble de valeurs, seulement redistribuées.",
"en": "An integer column is saturated: the copy holds the same set of values, only reshuffled.",
},
"Open the TUI for setup? (Y/n): ": {
"fr": "Ouvrir l'interface TUI pour paramétrage ? (O/n) : ",
"en": "Open the TUI for setup? (Y/n): ",
},
"Transform — anonymisation scope": {
"fr": "Transform — périmètre de l'anonymisation",
"en": "Transform — anonymisation scope",
},
"‹no label›": {
"fr": "‹sans libellé›",
"en": "‹no label›",
},
"column label": {
"fr": "libellé",
"en": "column label",
},
"column type": {
"fr": "type",
"en": "column type",
},
"filled": {
"fr": "remplies",
"en": "filled",
},
"distinct": {
"fr": "distinctes",
"en": "distinct",
},
"examples": {
"fr": "exemples",
"en": "examples",
},
"row no": {
"fr": "ligne",
"en": "row no",
},
"first values": {
"fr": "premières valeurs",
"en": "first values",
},
"measures": {
"fr": "mesures",
"en": "measures",
},
"sig type": {
"fr": "type",
"en": "sig type",
},
"off-col": {
"fr": "hors-col",
"en": "off-col",
},
"shape": {
"fr": "forme",
"en": "shape",
},
"Toggle the current row": {
"fr": "Cocher ou décocher la ligne courante",
"en": "Toggle the current row",
},
"Leave this column untouched": {
"fr": "Laisser cette colonne intacte",
"en": "Leave this column untouched",
},
"Clear this sheet": {
"fr": "Rendre cette feuille à la mesure",
"en": "Clear this sheet",
},
"Accept": {
"fr": "Accepter",
"en": "Accept",
},
"Fall back to text prompts": {
"fr": "Revenir aux invites textuelles",
"en": "Fall back to text prompts",
},
"[x] untouched · [!] floored · * corrected · = from the batch table": {
"fr": "[x] intacte · [!] planchéiée · * corrigée · = de la table du lot",
"en": "[x] untouched · [!] floored · * corrected · = from the batch table",
},
"[x] untouched · [!] floored, not answerable": {
"fr": "[x] intacte · [!] planchéiée, non répondable",
"en": "[x] untouched · [!] floored, not answerable",
},
"This column is floored: no answer applies.": {
"fr": "Cette colonne est planchéiée : aucune réponse n'y porte.",
"en": "This column is floored: no answer applies.",
},
"The screen needs textual; falling back to prompts.": {
"fr": "L'écran demande textual ; retour aux invites.",
"en": "The screen needs textual; falling back to prompts.",
},
"Preview — nothing written yet": {
"fr": "Aperçu — rien n'est encore écrit",
"en": "Preview — nothing written yet",
},
"Write? (Y/n): ": {
"fr": "Écrire ? (O/n) : ",
"en": "Write? (Y/n): ",
},
"Destination": {
"fr": "Destination",
"en": "Destination",
},
"Enter to accept, p to browse, or type a path: ": {
"fr": "Entrée pour accepter, p pour parcourir, ou taper un chemin : ",
"en": "Enter to accept, p to browse, or type a path: ",
},
"Written: ": {
"fr": "Écrit : ",
"en": "Written: ",
},
"cell(s) replaced": {
"fr": "cellule(s) remplacée(s)",
"en": "cell(s) replaced",
},
"cell(s) left out of scope": {
"fr": "cellule(s) laissée(s) hors portée",
"en": "cell(s) left out of scope",
},
"element(s) outside cells wiped": {
"fr": "élément(s) hors cellules effacé(s)",
"en": "element(s) outside cells wiped",
},
"column(s) left alone: they hold identifiers": {
"fr": "colonne(s) laissée(s) : elles portent des identifiants",
"en": "column(s) left alone: they hold identifiers",
},
"The original was not modified.": {
"fr": "L'original n'a pas été modifié.",
"en": "The original was not modified.",
},
"These files would be overwritten: ": {
"fr": "Ces fichiers seraient écrasés : ",
"en": "These files would be overwritten: ",
},
"This file already exists. Type its name in full to overwrite: ": {
"fr": "Ce fichier existe déjà. Taper son nom en entier pour l'écraser : ",
"en": "This file already exists. Type its name in full to overwrite: ",
},
"Name does not match, nothing was written.": {
"fr": "Le nom ne correspond pas, rien n'a été écrit.",
"en": "Name does not match, nothing was written.",
},
"Mapping table written: ": {
"fr": "Table de correspondance écrite : ",
"en": "Mapping table written: ",
},
"This table re-identifies the copy; keep it in private/.": {
"fr": "Cette table ré-identifie la copie ; la garder dans private/.",
"en": "This table re-identifies the copy; keep it in private/.",
},
"The file name is not anonymised; the default does not reuse it.": {
"fr": "Le nom du fichier n'est pas anonymisé ; le défaut ne le reprend pas.",
"en": "The file name is not anonymised; the default does not reuse it.",
},
"Delete all copies in private/transform/? (y/N): ": {
"fr": "Supprimer toutes les copies de private/transform/ ? (o/N) : ",
"en": "Delete all copies in private/transform/? (y/N): ",
},
"private/ is tracked by git — this file will show in « git status ».": {
"fr": "private/ est suivi par git — ce fichier apparaîtra dans « git status ».",
"en": "private/ is tracked by git — this file will show in « git status ».",
},
"Cached formula results are dropped; the sheet recomputes on open.": {
"fr": "Les résultats de formule en cache sont perdus ; la feuille recalcule à l'ouverture.",
"en": "Cached formula results are dropped; the sheet recomputes on open.",
},
"Document properties were cleared on the copy.": {
"fr": "Les propriétés du document ont été vidées sur la copie.",
"en": "Document properties were cleared on the copy.",
},
"Pivot tables and chart caches are removed: they hold an unanonymised copy of the source.": {
"fr": "Les tableaux croisés et les caches de graphique sont supprimés : ils portent une copie non anonymisée de la source.",
"en": "Pivot tables and chart caches are removed: they hold an unanonymised copy of the source.",
},
"Images and drawings are not carried over to the copy.": {
"fr": "Les images et les dessins ne sont pas repris dans la copie.",
"en": "Images and drawings are not carried over to the copy.",
},
"Charts are not carried over to the copy.": {
"fr": "Les graphiques ne sont pas repris dans la copie.",
"en": "Charts are not carried over to the copy.",
},
"Charts are kept: their caches are cleaned through private attributes.": {
"fr": "Les graphiques sont conservés : leurs caches sont nettoyés par des attributs privés.",
"en": "Charts are kept: their caches are cleaned through private attributes.",
},
"External links were dropped; formulas that used them show #REF!.": {
"fr": "Les liens externes sont retirés ; les formules qui s'en servaient affichent #REF!.",
"en": "External links were dropped; formulas that used them show #REF!.",
},
"Range and table names are kept so formulas resolve; they may hold identifying strings.": {
"fr": "Les noms de plages et de tableaux sont conservés pour que les formules résolvent ; ils peuvent porter des chaînes identifiantes.",
"en": "Range and table names are kept so formulas resolve; they may hold identifying strings.",
},
"Sheet names are kept so formulas resolve; they may identify.": {
"fr": "Les noms de feuille sont conservés pour que les formules résolvent ; ils peuvent identifier.",
"en": "Sheet names are kept so formulas resolve; they may identify.",
},
"The VBA project and its companions (form controls, ActiveX, VML shapes, ribbon, EMF images) are copied as they are and were not reviewed.": {
"fr": "Le projet VBA et ce qui l'accompagne (contrôles de formulaire, ActiveX, formes VML, ruban, images EMF) sont recopiés tels quels et n'ont pas été relus.",
"en": "The VBA project and its companions (form controls, ActiveX, VML shapes, ribbon, EMF images) are copied as they are and were not reviewed.",
},
"Format not recognised: ": {
"fr": "Format non reconnu : ",
"en": "Format not recognised: ",
},
"Recognised format, unreadable here — re-save it as .xlsx.": {
"fr": "Format reconnu, illisible ici — le ré-enregistrer en .xlsx.",
"en": "Recognised format, unreadable here — re-save it as .xlsx.",
},
"Protected by a password, or not a workbook — unreadable here.": {
"fr": "Protégé par un mot de passe, ou pas un classeur — illisible ici.",
"en": "Protected by a password, or not a workbook — unreadable here.",
},
"Empty file.": {
"fr": "Fichier vide.",
"en": "Empty file.",
},
"Not readable: check the permissions.": {
"fr": "Illisible : vérifier les droits.",
"en": "Not readable: check the permissions.",
},
"Not an ordinary file.": {
"fr": "Pas un fichier ordinaire.",
"en": "Not an ordinary file.",
},
"The destination is the source file; nothing was written.": {
"fr": "La destination est le fichier source ; rien n'a été écrit.",
"en": "The destination is the source file; nothing was written.",
},
"The destination directory exists and is not empty.": {
"fr": "Le répertoire de destination existe et n'est pas vide.",
"en": "The destination directory exists and is not empty.",
},
"The selection matches no sheet; nothing was written.": {
"fr": "La sélection ne correspond à aucune feuille ; rien n'a été écrit.",
"en": "The selection matches no sheet; nothing was written.",
},
"Nothing was anonymised: every region was excluded.": {
"fr": "Rien n'a été anonymisé : toutes les régions ont été exclues.",
"en": "Nothing was anonymised: every region was excluded.",
},
"Nothing to anonymise in this file.": {
"fr": "Rien à anonymiser dans ce fichier.",
"en": "Nothing to anonymise in this file.",
},
"Not enough room to write.": {
"fr": "Pas assez de place pour écrire.",
"en": "Not enough room to write.",
},
# Transform data - correctifs de fuite
"A source value survives in the copy; nothing was written: ": {
"fr": "Une valeur de la source subsiste dans la copie ; rien n'a été écrit : ",
"en": "A source value survives in the copy; nothing was written: ",
},
"The mapping table would overwrite the source or the copy; nothing was written.": {
"fr": "La table de correspondance écraserait la source ou la copie ; rien n'a été écrit.",
"en": "The mapping table would overwrite the source or the copy; nothing was written.",
},
"Sheets outside the selection are dropped from the copy; formulas that referenced them show #REF!.": {
"fr": "Les feuilles hors de la sélection sont retirées de la copie ; les formules qui les référençaient affichent #REF!.",
"en": "Sheets outside the selection are dropped from the copy; formulas that referenced them show #REF!.",
},
"Chart sheets are dropped from the copy: their titles and series caches are not cells.": {
"fr": "Les feuilles graphiques sont retirées de la copie : leurs titres et leurs caches de série ne sont pas des cellules.",
"en": "Chart sheets are dropped from the copy: their titles and series caches are not cells.",
},
"Object keys are kept as structure; they may identify.": {
"fr": "Les clés d'objet sont conservées comme structure ; elles peuvent identifier.",
"en": "Object keys are kept as structure; they may identify.",
},
"Element and attribute names are kept as structure; they may identify.": {
"fr": "Les noms de balise et d'attribut sont conservés comme structure ; ils peuvent identifier.",
"en": "Element and attribute names are kept as structure; they may identify.",
},
"The mapping table is unreadable, or is not a mapping table: ": {
"fr": "La table de correspondance est illisible, ou n'est pas une table de correspondance : ",
"en": "The mapping table is unreadable, or is not a mapping table: ",
},
"Leave the mapping table question empty to create a new one.": {
"fr": "Laisser vide la question de la table pour en créer une neuve.",
"en": "Leave the mapping table question empty to create a new one.",
},
"A table of that name exists; using a new one.": {
"fr": "Une table de ce nom existe ; une neuve est prise.",
"en": "A table of that name exists; using a new one.",
},
"column(s) entirely in clear": {
"fr": "colonne(s) entièrement en clair",
"en": "column(s) entirely in clear",
},
"column(s) without examples": {
"fr": "colonne(s) sans exemple",
"en": "column(s) without examples",
},
"These cells are copied verbatim:": {
"fr": "Ces cellules sont recopiées telles quelles :",
"en": "These cells are copied verbatim:",
},
"more, not listed": {
"fr": "autres, non listées",
"en": "more, not listed",
},
"Correct the header rows to anonymise them.": {
"fr": "Corriger les lignes d'en-tête pour les anonymiser.",
"en": "Correct the header rows to anonymise them.",
},
"Mapping table: ": {
"fr": "Table de correspondance : ",
"en": "Mapping table: ",
},
"value(s) not verified, above the cap": {
"fr": "valeur(s) non vérifiée(s), au-delà du plafond",
"en": "value(s) not verified, above the cap",
},
"Not produced here, left alone:": {
"fr": "Non produit ici, laissé en place :",
"en": "Not produced here, left alone:",
},
"The library here cannot read this file: ": {
"fr": "La bibliothèque ne sait pas lire ce fichier ici : ",
"en": "The library here cannot read this file: ",
},
"This target cannot hold the source's shape: ": {
"fr": "Cette cible ne peut pas porter la forme de la source : ",
"en": "This target cannot hold the source's shape: ",
},
} }

View file

@ -0,0 +1,555 @@
#!/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 textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from rich.text import Text
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"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,236 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'environnement de lecture des fichiers externes, posé à la demande.
Excel et Access exigent des bibliothèques qui ne sont dans aucun venv du
dépôt : `.venv.erplibre` porte l'outillage du CLI, le venv Odoo porte celui
d'Odoo, et charger l'un ou l'autre de ces lecteurs les mélangerait à un
sujet qui n'est pas le leur. D'où un venv dédié, bâti au premier besoin.
Les formats en pur stdlib n'en ont PAS besoin, et c'est ce que
`available()` et `engine_python()` tiennent à la place des appelants : un
CSV ne doit jamais déclencher la construction d'un venv.
La pose d'un paquet SYSTÈME ne se réécrit pas ici : `todo_install` la fait
déjà pour les quatre familles, et il est testé. On l'appelle.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
try:
from script.todo import todo_install
except Exception: # pragma: no cover - repli hors dépôt
todo_install = None
NOM_VENV = ".venv.todo.external_data"
REQUIREMENTS = os.path.join("requirement", "todo_external_data.txt")
# Ce que chaque format exige d'importable. Les formats absents de cette
# table sont en pur stdlib et ne demandent rien.
IMPORTS_PAR_FORMAT = {
"xlsx": ("openpyxl",),
"xls": ("xlrd",),
"access": ("access_parser",),
}
# Les formats servis par l'interpréteur du CLI, sans venv.
FORMATS_STDLIB = ("csv", "json", "xml", "macros")
# mdbtools rend les requêtes enregistrées d'une base Access lisibles.
# PAS d'entrée `pacman` : le paquet n'est pas dans les dépôts officiels
# d'Arch — seulement l'AUR — et `install_command` doit alors rendre None
# plutôt qu'une commande qui échoue APRÈS le mot de passe sudo.
PAQUETS_ACCESS = {
"apt-get": ["mdbtools"],
"dnf": ["mdbtools"],
"zypper": ["mdbtools"],
}
# Le binaire qui les lit. La PRÉSENCE se demande au PATH, jamais au
# gestionnaire de paquets : sur une distribution dont ce module ne connaît
# pas le paquet — Arch, où mdbtools n'est qu'à l'AUR — l'absence de
# commande d'installation était rendue comme l'absence de l'outil, et
# l'entrée disait « aucun paquet connu » devant un `mdb-queries` installé.
BINAIRE_REQUETES = "mdb-queries"
def racine() -> str:
"""La racine du dépôt, quel que soit le répertoire courant."""
return os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
def venv_path() -> str:
return os.path.join(racine(), NOM_VENV)
def python_path() -> str | None:
"""L'interpréteur du venv dédié, ou None s'il n'existe pas."""
candidat = os.path.join(venv_path(), "bin", "python")
return candidat if os.path.isfile(candidat) else None
def engine_python(fmt: str | None = None) -> str:
"""L'interpréteur qui servira ce format.
`sys.executable` pour les formats en pur stdlib — le moteur s'y importe
déjà, ses imports tiers étant paresseux — et en repli quand le venv
dédié n'existe pas encore, pour que l'appelant obtienne toujours un
chemin exécutable plutôt qu'un None à tester.
"""
if fmt in FORMATS_STDLIB:
return sys.executable
return python_path() or sys.executable
def _importable_par(interpreteur: str, module: str) -> bool:
try:
acheve = subprocess.run(
[interpreteur, "-c", f"import {module}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=60,
)
return acheve.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def available(fmt: str | None = None) -> bool:
"""Ce format est-il lisible ici, maintenant ?
Vrai d'office pour les formats en pur stdlib : exiger un venv pour lire
un CSV ferait construire un environnement pour rien.
"""
if fmt in FORMATS_STDLIB:
return True
interpreteur = python_path()
if not interpreteur:
return False
modules = IMPORTS_PAR_FORMAT.get(fmt) if fmt else ("openpyxl",)
return all(_importable_par(interpreteur, m) for m in modules or ())
def requetes_access_lisibles() -> bool:
"""`mdb-queries` est-il sur le PATH ?
Les requêtes enregistrées d'une base Access ne se lisent pas en pur
Python : `access-parser` rend les TABLES, et le catalogue où vivent
les requêtes est écarté exprès. Cette question est donc distincte de
« le format access est-il lisible », qui reste vraie sans mdbtools.
"""
return shutil.which(BINAIRE_REQUETES) is not None
def capabilities() -> dict:
"""Ce que la machine sait lire. Pour l'entrée du même nom.
Les requêtes enregistrées y figurent comme une entrée à part : elles
ne viennent pas du venv mais d'un paquet système, et une base Access
reste lisible sans elles — seulement, ce qu'elles portent n'est alors
ni lu ni compté, et l'opérateur doit le savoir avant de transmettre.
"""
etat = {nom: True for nom in FORMATS_STDLIB}
for fmt in IMPORTS_PAR_FORMAT:
etat[fmt] = available(fmt)
etat["xlsb"] = False
etat["access queries"] = requetes_access_lisibles()
return etat
def create(ask=input, executeur=None) -> bool:
"""Bâtir le venv puis y poser les requirements. Booléen.
La commande s'affiche AVANT la question : on approuve ce qu'on a lu.
"""
cible = venv_path()
requirements = os.path.join(racine(), REQUIREMENTS)
if not os.path.isfile(requirements):
print(f" ⚠ {t('Not readable: check the permissions.')}")
print(f" {requirements}")
return False
creation = [sys.executable, "-m", "venv", cible]
print(f" {t('Will execute:')} {' '.join(creation)}")
print(
f" {t('Will execute:')} {os.path.join(cible, 'bin', 'pip')}"
f" install -r {REQUIREMENTS}"
)
if not _oui(ask(t("Create it now? (Y/n): "))):
print(t("Nothing to do."))
return False
if not python_path():
code = _lancer(creation, executeur)
if code:
print(f" ⚠ {t('python -m venv exited with')} {code}")
return False
pip = os.path.join(cible, "bin", "pip")
code = _lancer([pip, "install", "-r", requirements], executeur)
if code:
print(f" ⚠ {t('pip exited with')} {code}")
if available("xlsx"):
print(f"✅ {t('The environment is ready.')}")
return True
print(f" ⚠ {t('Creation finished but the libraries are still missing.')}")
return False
def _lancer(commande, executeur=None) -> int:
if executeur is not None:
return executeur(commande)
try:
return subprocess.run(commande).returncode
except (OSError, subprocess.SubprocessError) as exc:
print(f" ⚠ {exc}")
return 1
def _oui(reponse) -> bool:
"""« Défaut oui » : la réponse vide accepte."""
valeur = str(reponse or "").strip().lower()
return valeur == "" or valeur in ("y", "yes", "o", "oui")
def ensure(fmt: str, prompt: bool = True, ask=input) -> bool:
"""Le format est-il lisible ? Sinon, proposer d'y remédier.
`prompt=False` se contente de constater — pour les appels qui ne
peuvent pas poser de question.
"""
if available(fmt):
return True
print(
f"\n⚠ {t('A dedicated environment is required to read this format.')}"
)
if not prompt:
return False
return create(ask=ask)
def system_packages_cmd():
"""La commande qui pose `mdbtools`, ou None si personne ne le connaît.
Un simple passe-plat vers `todo_install`, qui décide déjà de la famille
par l'ID de /etc/os-release avant le PATH. Écrire une cascade de plus
serait refaire le défaut que ce module a supprimé.
"""
if todo_install is None: # pragma: no cover - hors dépôt
return None
return todo_install.install_command(PAQUETS_ACCESS)

View file

@ -24,6 +24,9 @@ blanche, aucune insistance ne doit permettre d'écrire dans `ir.*`.
""" """
import ast import ast
import io
import math
import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
@ -578,6 +581,143 @@ class TestACheckDoesNotSilenceTheMainField(unittest.TestCase):
for motif in ("char_length", "~~", "jsonb_typeof"): for motif in ("char_length", "~~", "jsonb_typeof"):
self.assertIn(motif, anon.REQUETE_CHAMPS, motif) self.assertIn(motif, anon.REQUETE_CHAMPS, motif)
def _champ_calibre(self, ttype="integer"):
return {
"model": "res.partner",
"name": "montant",
"ttype": ttype,
"pg_type": "numeric",
"unique": False,
"checked": False,
"max_len": None,
"borne_min": 1,
"borne_max": 99999,
}
def test_the_width_rule_reads_each_ROW(self):
"""La décade se lit sur la valeur de la ligne, pas sur la colonne.
Une colonne mêle des largeurs, et c'est la largeur de la VALEUR
que l'option promet de garder.
"""
sql = anon.expression_calibre(self._champ_calibre())
self.assertIn('trunc(abs("montant"::numeric))', sql)
self.assertIn("power(10::numeric", sql)
# `floor(log(...))` travaillait en flottant : log(999999999999999)
# y vaut 15 tout rond, et la décade gagnait un rang.
self.assertNotIn("floor(log(", sql)
def test_the_three_cases_the_rule_does_not_cover(self):
"""NULL, zéro et sous l'unité retombent sur l'étendue mesurée.
0,15 n'a aucun chiffre avant la virgule : appliquer la règle y
tirerait un taux entre 1 et 9, ce que l'étendue existe pour
empêcher.
"""
sql = anon.expression_calibre(self._champ_calibre("float"))
self.assertIn('WHEN "montant" IS NULL THEN NULL', sql)
self.assertIn('WHEN "montant" = 0 THEN "montant"', sql)
self.assertIn('WHEN abs("montant"::numeric) < 1', sql)
def test_the_sign_survives(self):
for ttype in ("integer", "float", "monetary"):
with self.subTest(ttype=ttype):
sql = anon.expression_calibre(self._champ_calibre(ttype))
self.assertIn('sign("montant")', sql)
def test_an_integer_field_gets_a_WHOLE_number(self):
"""Un `float` rendu dans un champ entier ne se réimporte plus.
Ce qui garantit l'entier est `floor`, non la coulée : celle-ci
suit le type qui accueille, et un `integer` d'Odoo peut vivre
dans une colonne `numeric` où `::integer` lèverait.
"""
sql = anon.expression_calibre(self._champ_calibre("integer"))
self.assertIn("floor(", sql)
self.assertNotIn("::numeric, 2)", sql.split("ELSE")[-1])
sql = anon.expression_calibre(self._champ_calibre("float"))
self.assertIn("::numeric, 2)", sql)
def test_the_cast_follows_the_column_when_it_is_a_bounded_int(self):
champ = dict(self._champ_calibre("integer"), pg_type="integer")
self.assertIn("::integer", anon.expression_calibre(champ))
def test_the_option_is_OFF_by_default(self):
"""L'étendue mesurée est ce qui protège une heure ou un taux :
elle reste la règle."""
champs = [self._champ_calibre()]
mots = {"MOTS": ["aa", "bb"]}
sans = anon.plan(champs, mode="blacklist", mots=mots)
avec = anon.plan(champs, mode="blacklist", mots=mots, calibre=True)
self.assertNotIn("power(10", sans[0]["sql"])
self.assertIn("power(10", avec[0]["sql"])
def test_the_dry_run_and_the_write_build_the_SAME_sql(self):
"""`appliquer_sondes` refait le SQL : sans l'option, la marche à
blanc montrerait autre chose que ce que `--apply` écrit."""
champs = [self._champ_calibre()]
mots = {"MOTS": ["aa", "bb"]}
etapes = anon.plan(champs, mode="blacklist", mots=mots, calibre=True)
refait = anon.appliquer_sondes(
etapes, {}, {"res.partner": {"montant": (1, 99)}}, mots, True
)
self.assertIn("power(10", refait[0]["sql"])
def _champ_numerique(self, unique):
return {
"model": "res.partner",
"name": "ref",
"ttype": "integer",
"pg_type": "int4",
"unique": unique,
"checked": False,
"max_len": None,
}
def test_a_unique_number_is_left_alone(self):
"""Aucun tirage ne garantit son unicité.
`expression_texte` colle l'id sur une colonne unique ; un nombre
n'a pas cette issue — y coller l'id changerait sa grandeur. Deux
lignes au même nombre font échouer l'UPDATE, et transaction
unique oblige, TOUTE l'anonymisation avec.
"""
self.assertFalse(anon.champ_retenu(self._champ_numerique(True)))
self.assertTrue(anon.champ_retenu(self._champ_numerique(False)))
def test_a_unique_TEXT_column_is_still_anonymised(self):
"""L'abstention ne vaut que pour les nombres : le texte a l'id
collé, et c'est lui qui porte l'unicité."""
champ = dict(
self._champ_numerique(True),
name="vat",
ttype="char",
pg_type="character varying",
)
self.assertTrue(anon.champ_retenu(champ))
def test_the_report_NAMES_what_it_left_alone(self):
"""Le taire laisserait une colonne identifiante partir sans que
rien ne le dise."""
champs = [
{
"model": "res.partner",
"name": "name",
"ttype": "char",
"pg_type": "character varying",
"unique": False,
"checked": False,
"max_len": None,
},
self._champ_numerique(True),
]
etapes = anon.plan(
champs, mode="blacklist", mots={"MOTS": ["aa", "bb"]}
)
self.assertEqual(etapes[0]["abstenus"], ["ref"])
self.assertNotIn("ref", [c["name"] for c in etapes[0]["fields"]])
self.assertIn("ref", anon.render(etapes))
def test_a_field_the_query_cleared_is_anonymised(self): def test_a_field_the_query_cleared_is_anonymised(self):
"""`checked=False` doit suffire : aucune seconde barrière cachée.""" """`checked=False` doit suffire : aucune seconde barrière cachée."""
champ = { champ = {
@ -893,5 +1033,788 @@ class TestTheProbeDistrustsWhatItReads(unittest.TestCase):
self.assertEqual(len(bornes["m"]), 2) self.assertEqual(len(bornes["m"]), 2)
class TestTheCalibreCannotLeaveTheColumnType(unittest.TestCase):
"""Garder la largeur ne doit pas viser plus haut que la colonne ne tient.
PostgreSQL borne ses entiers par TAILLE. Une valeur à 10 chiffres dans
une colonne `integer` tire dans une bande qui monte à 9 999 999 999,
là où le type s'arrête à 2 147 483 647 : le dépassement lève, et
l'écriture tenant en une transaction unique, il emporte TOUTE
l'anonymisation. `smallint` est plus étroit encore — 32 767 — et une
valeur à 5 chiffres y suffit.
"""
def _champ(self, ttype="integer", pg="integer"):
return {
"model": "res.partner",
"name": "montant",
"ttype": ttype,
"pg_type": pg,
"unique": False,
"checked": False,
"max_len": None,
"borne_min": 1,
"borne_max": 99999,
}
def _tirer(self, valeur, pg="integer", entier=True, hasard=0.5):
"""Un MODÈLE de l'arithmétique du SQL, et non le SQL lui-même.
Il tourne sans base et couvre toute la plage de `random()` d'un
seul balayage, ce qu'une base rend coûteux. Mais il ne prouve que
lui-même : c'est `TestTheEmittedSqlUnderPostgres` qui éprouve la
chaîne réellement émise, et les deux doivent rester d'accord.
"""
decade = 10 ** (len(str(abs(int(valeur)))) - 1)
haut = decade * 10 - 1
coulee = anon.type_de_coulee({"ttype": "integer", "pg_type": pg})
if coulee in anon.PLAFOND_ENTIER:
haut = min(haut, anon.PLAFOND_ENTIER[coulee])
signe = -1 if valeur < 0 else 1
if entier:
return signe * math.floor(decade + hasard * (haut - decade + 1))
return round(signe * (decade + hasard * (haut - decade)), 2)
def test_each_integer_width_has_its_own_ceiling(self):
for pg, plafond in (
("smallint", 32767),
("integer", 2147483647),
("bigint", 9223372036854775807),
):
with self.subTest(pg=pg):
sql = anon.expression_calibre(self._champ(pg=pg))
self.assertIn("least(", sql)
self.assertIn(str(plafond), sql)
def test_the_alias_spelling_is_understood(self):
"""Un dump écrit `int4` là où `regtype` rend `integer`."""
for alias, canon in (
("int2", "smallint"),
("int4", "integer"),
("int8", "bigint"),
):
with self.subTest(alias=alias):
self.assertEqual(canon, anon.type_entier(alias))
def test_a_type_with_no_ceiling_keeps_the_open_band(self):
"""`numeric` et `double precision` n'ont aucun plafond à tenir."""
for pg in ("numeric", "double precision"):
with self.subTest(pg=pg):
self.assertIsNone(anon.type_entier(pg))
sql = anon.expression_calibre(self._champ("float", pg))
self.assertNotIn("least(", sql)
def test_the_cast_follows_the_column_and_is_not_always_integer(self):
"""Couler un tirage bigint en `::integer` lèverait à son tour."""
self.assertIn(
"::bigint", anon.expression_calibre(self._champ(pg="bigint"))
)
self.assertIn(
"::smallint", anon.expression_calibre(self._champ(pg="smallint"))
)
def test_a_ten_digit_integer_never_leaves_int4(self):
for cran in range(1000):
tire = self._tirer(2000000000, "integer", hasard=cran / 1000.0)
self.assertLessEqual(tire, 2147483647, cran)
self.assertGreaterEqual(tire, 10**9, cran)
def test_a_five_digit_smallint_never_leaves_int2(self):
for cran in range(1000):
tire = self._tirer(20000, "smallint", hasard=cran / 1000.0)
self.assertLessEqual(tire, 32767, cran)
self.assertGreaterEqual(tire, 10000, cran)
def test_the_sign_survives_the_ceiling(self):
for cran in range(1000):
tire = self._tirer(-2000000000, "integer", hasard=cran / 1000.0)
self.assertLess(tire, 0, cran)
self.assertGreaterEqual(tire, -2147483647, cran)
def test_the_top_of_the_band_is_reachable(self):
"""Sans le `+ 1`, 8839 ne pouvait jamais sortir 9999."""
atteints = {
self._tirer(8839, "numeric", hasard=c / 10000.0)
for c in range(10000)
}
self.assertEqual(1000, min(atteints))
self.assertEqual(9999, max(atteints))
def test_rounding_never_adds_a_digit(self):
"""`round(…, 2)` au haut de la bande rendrait 10000,0 : un chiffre
de plus, ce que l'option existe pour empêcher."""
for cran in range(10000):
tire = self._tirer(
8839.5, "numeric", entier=False, hasard=cran / 10000.0
)
self.assertLess(tire, 10000, cran)
self.assertGreaterEqual(tire, 1000, cran)
def test_the_measure_casts_before_taking_the_absolute_value(self):
"""`abs()` du plus petit entier signé lève : sa valeur absolue ne
tient pas dans son propre type."""
sql = anon.expression_calibre(self._champ())
self.assertIn('abs("montant"::numeric)', sql)
self.assertEqual(
sql.count('abs("montant"'),
sql.count('abs("montant"::numeric)'),
)
class TestWhyAColumnIsLeftAlone(unittest.TestCase):
"""La raison d'une abstention se LIT, elle ne se redevine pas.
Le rapport ne nomme qu'un motif — l'unicité numérique, la seule dont
le silence laisserait partir une colonne identifiante. Le redeviner
depuis le type et l'unicité nommait `id` sur CHAQUE modèle, `id`
étant un entier unique que le PLANCHER refuse ; et une colonne
`xxx_id`, sous contrainte CHECK ou en jsonb numérique se confondait
de la même façon.
"""
def _f(self, nom, ttype="integer", pg="integer", **kw):
return {
"model": kw.pop("modele", "res.partner"),
"name": nom,
"ttype": ttype,
"pg_type": pg,
"unique": kw.pop("unique", False),
"checked": kw.pop("checked", False),
"max_len": None,
}
def test_the_primary_key_is_refused_by_the_FLOOR(self):
"""`id` est un entier unique : c'est le plancher qui l'écarte."""
self.assertEqual(
anon.REFUS_PLANCHER,
anon.raison_du_refus(self._f("id", unique=True)),
)
def test_each_neighbour_gives_its_OWN_reason(self):
"""Quatre champs satisfont « nombre unique » sans être celui-là."""
for nom, kw, attendu in (
("id", {"unique": True}, anon.REFUS_PLANCHER),
("partner_id", {"unique": True}, anon.REFUS_RELATION),
(
"compteur",
{"unique": True, "checked": True},
anon.REFUS_CONTRAINTE,
),
(
"credit_limit",
{"unique": True, "pg": "jsonb"},
anon.REFUS_JSONB_NOMBRE,
),
):
with self.subTest(nom=nom):
champ = self._f(nom, **kw)
self.assertEqual(attendu, anon.raison_du_refus(champ))
self.assertNotEqual(
anon.REFUS_NOMBRE_UNIQUE, anon.raison_du_refus(champ)
)
def test_the_reason_the_report_names_is_still_reached(self):
self.assertEqual(
anon.REFUS_NOMBRE_UNIQUE,
anon.raison_du_refus(self._f("numero", unique=True)),
)
def test_the_two_questions_never_disagree(self):
"""`champ_retenu` n'est que la même question posée en oui/non."""
for champ in (
self._f("id", unique=True),
self._f("numero", unique=True),
self._f("name", "char", "character varying"),
self._f("login", "char", "character varying"),
self._f("parent_path", "char", "character varying"),
):
with self.subTest(nom=champ["name"]):
self.assertEqual(
anon.raison_du_refus(champ) is None,
anon.champ_retenu(champ),
)
def test_the_login_reason_follows_the_option(self):
champ = self._f("login", "char", "character varying")
self.assertEqual(anon.REFUS_CONNEXION, anon.raison_du_refus(champ))
self.assertIsNone(anon.raison_du_refus(champ, True))
def test_the_plan_does_not_name_the_primary_key(self):
"""Une ligne ⚠ par modèle noyait les vrais avertissements."""
champs = [
self._f("id", unique=True),
self._f("name", "char", "character varying"),
]
etapes = anon.plan(
champs, mode="blacklist", mots={"MOTS": ["aa", "bb"]}
)
self.assertEqual([], etapes[0]["abstenus"])
self.assertNotIn("⚠", anon.render(etapes))
class TestTheWarningSurvivesAnEmptyPlan(unittest.TestCase):
"""Le seul cas pour lequel l'avertissement existe le perdait.
Un modèle dont la seule colonne anonymisable EST la numérique unique
ne produit aucun UPDATE. L'étape était écartée, l'avertissement avec,
et le rapport affirmait « rien à anonymiser » sur la colonne même
qu'il existe pour nommer.
"""
MOTS = {"MOTS": ["aa", "bb"]}
def _f(self, nom, ttype="integer", pg="integer", **kw):
return {
"model": kw.pop("modele", "x.compteur"),
"name": nom,
"ttype": ttype,
"pg_type": pg,
"unique": kw.pop("unique", False),
"checked": False,
"max_len": None,
}
def test_a_model_with_only_that_column_keeps_its_warning(self):
etapes = anon.plan(
[self._f("numero", unique=True)],
mode="blacklist",
mots=self.MOTS,
)
self.assertEqual(1, len(etapes))
self.assertEqual(["numero"], etapes[0]["abstenus"])
def test_such_a_step_carries_NO_sql_and_no_field(self):
"""C'est ce que lisent le code de sortie et l'écriture."""
etapes = anon.plan(
[self._f("numero", unique=True)],
mode="blacklist",
mots=self.MOTS,
)
self.assertIsNone(etapes[0]["sql"])
self.assertEqual([], etapes[0]["fields"])
def test_the_report_NAMES_it_and_still_says_nothing_to_do(self):
etapes = anon.plan(
[self._f("numero", unique=True)],
mode="blacklist",
mots=self.MOTS,
)
rapport = anon.render(etapes)
self.assertIn("numero", rapport)
self.assertIn(anon.t("left in clear, numeric and unique:"), rapport)
self.assertIn(
anon.t("Nothing to anonymise with these lists."), rapport
)
def test_such_a_report_does_not_invite_to_write(self):
"""Il n'y a rien à écrire : proposer --apply serait un piège."""
etapes = anon.plan(
[self._f("numero", unique=True)],
mode="blacklist",
mots=self.MOTS,
)
self.assertNotIn(
anon.t("Use --apply --confirm <database> to write."),
anon.render(etapes),
)
def test_a_real_step_still_invites_to_write(self):
etapes = anon.plan(
[
self._f("numero", unique=True),
self._f("libelle", "char", "character varying"),
],
mode="blacklist",
mots=self.MOTS,
)
rapport = anon.render(etapes)
self.assertIn(
anon.t("Use --apply --confirm <database> to write."), rapport
)
self.assertIn(anon.t("left in clear, numeric and unique:"), rapport)
def test_the_probe_taking_the_last_column_keeps_the_warning(self):
"""Le second endroit où l'avertissement disparaissait."""
etapes = anon.plan(
[
self._f("ref_interne", unique=True),
self._f("chemin", "char", "character varying"),
],
mode="blacklist",
mots=self.MOTS,
)
apres = anon.appliquer_sondes(
etapes, {"x.compteur": ["chemin"]}, {}, self.MOTS
)
self.assertEqual(1, len(apres))
self.assertIsNone(apres[0]["sql"])
self.assertEqual(["ref_interne"], apres[0]["abstenus"])
self.assertIn("ref_interne", anon.render(apres))
def test_a_step_with_neither_sql_nor_warning_is_dropped(self):
"""Sans avertissement à porter, une étape vide n'a rien à dire."""
etapes = anon.plan(
[self._f("chemin", "char", "character varying")],
mode="blacklist",
mots=self.MOTS,
)
apres = anon.appliquer_sondes(
etapes, {"x.compteur": ["chemin"]}, {}, self.MOTS
)
self.assertEqual([], apres)
def _postgres_joignable():
"""PostgreSQL répond-il ? Le lanceur unitaire n'en exige aucun."""
try:
from script.analyse import lib_analyse
lib_analyse.run_psql("postgres", "SELECT 1", timeout=5)
return True
except Exception: # noqa: BLE001 - absent, refusé, injoignable : pareil
return False
PG_JOIGNABLE = _postgres_joignable()
@unittest.skipUnless(PG_JOIGNABLE, "aucun PostgreSQL joignable")
class TestTheEmittedSqlUnderPostgres(unittest.TestCase):
"""Le SQL RÉELLEMENT ÉMIS, évalué par PostgreSQL.
Un test bâti sur une transcription Python prouve la transcription.
Les deux décisions d'arithmétique de `expression_calibre` — le
plafond du type d'ARRIVÉE et le compte exact des chiffres — ne se
vérifient que dans la chaîne émise, sur le moteur qui l'exécutera.
Aucune table, aucune écriture : l'expression est évaluée sur une
liste de VALUES, et `pg_env` impose `default_transaction_read_only`.
Sans base joignable, le test SE DIT ignoré plutôt que de passer au
vert en silence.
"""
LIGNES = 3000
def _champ(self, ttype, pg, bmin, bmax):
return {
"model": "m",
"name": "montant",
"ttype": ttype,
"pg_type": pg,
"unique": False,
"checked": False,
"max_len": None,
"borne_min": bmin,
"borne_max": bmax,
}
def _largeurs_fautives(self, ttype, pg, bmin, bmax, valeurs):
"""Combien de tirages n'ont pas la largeur de leur source.
`random()` reste RÉEL : y substituer une constante laisse
PostgreSQL replier l'expression au plan, et lever alors sur une
branche que le CASE n'atteint jamais à l'exécution.
"""
from script.analyse import lib_analyse
expr = anon.expression_calibre(self._champ(ttype, pg, bmin, bmax))
source = ", ".join("((%s)::%s)" % (v, pg) for v in valeurs)
entiere = "ltrim(split_part(%s::text, '.', 1), '-')"
sql = (
"SELECT count(*) FILTER (WHERE length(%s) <> length(%s))"
' FROM (VALUES %s) t("montant"), generate_series(1, %d)'
% (
entiere % 't."montant"',
entiere % ("(%s)" % expr),
source,
self.LIGNES,
)
)
return int(lib_analyse.run_psql("postgres", sql, timeout=30).strip())
def test_an_odoo_integer_on_a_numeric_column_does_not_raise(self):
"""La bande s'INVERSAIT : son bas dépassait le plafond d'`integer`
dont on bornait le haut, et chaque ligne levait — emportant, en
transaction unique, toute l'anonymisation."""
self.assertEqual(
0,
self._largeurs_fautives(
"integer",
"numeric",
1,
10**12,
[10000000000, 500000000000, 2147483648],
),
)
def test_the_same_on_double_precision(self):
self.assertEqual(
0,
self._largeurs_fautives(
"integer", "double precision", 1, 10**12, [10000000000]
),
)
def test_a_bounded_integer_column_never_overflows(self):
for pg, bmax, valeurs in (
("smallint", 32767, [1, 999, 10000, 32767, -32768]),
(
"integer",
2000000000,
[1, 1000000000, 2147483647, -2147483648],
),
(
"bigint",
10**18,
[10**18, 999999999999999999, 9223372036854775807],
),
):
with self.subTest(pg=pg):
self.assertEqual(
0,
self._largeurs_fautives("integer", pg, 1, bmax, valeurs),
)
def test_the_digit_count_is_exact_past_fifteen(self):
"""`floor(log(999999999999999))` vaut 15 tout rond en flottant :
la décade gagnait un rang, et la copie un chiffre."""
self.assertEqual(
0,
self._largeurs_fautives(
"integer",
"numeric",
1,
10**20,
[
999999999999999,
9999999999999999,
999999999999999999,
10**19 - 1,
],
),
)
def test_the_decimal_branch_keeps_its_width_too(self):
for pg in ("numeric", "double precision"):
with self.subTest(pg=pg):
self.assertEqual(
0,
self._largeurs_fautives(
"float",
pg,
1,
10**9,
[8839.5, 999999999999999.0, -1234.56],
),
)
def test_the_smallest_signed_integer_does_not_break_abs(self):
"""`abs()` du minimum d'un type lève : sa valeur absolue ne tient
pas dans ce type. La mesure passe donc par `numeric`."""
for pg, mini in (
("smallint", -32768),
("integer", -2147483648),
("bigint", -9223372036854775808),
):
with self.subTest(pg=pg):
self.assertEqual(
0,
self._largeurs_fautives(
"integer", pg, 1, abs(mini) - 1, [mini]
),
)
class TestWhereADrawIsPoured(unittest.TestCase):
"""Le type qui ACCUEILLE le tirage n'est pas celui d'Odoo.
Une base montée de version garde la colonne `numeric` qu'un champ
`Float` avait créée, `ir_model_fields` disant désormais `integer` :
Odoo ne réécrit pas le type d'une colonne quand le champ change. Y
couler en `integer` lève dès 2 147 483 648.
"""
def test_a_bounded_integer_column_keeps_its_own_type(self):
for pg, attendu in (
("smallint", "smallint"),
("integer", "integer"),
("bigint", "bigint"),
("int2", "smallint"),
("int4", "integer"),
("int8", "bigint"),
):
with self.subTest(pg=pg):
self.assertEqual(
attendu,
anon.type_de_coulee({"ttype": "integer", "pg_type": pg}),
)
def test_an_integer_field_on_an_unbounded_column_pours_numeric(self):
for pg in ("numeric", "double precision", "real"):
with self.subTest(pg=pg):
self.assertEqual(
"numeric",
anon.type_de_coulee({"ttype": "integer", "pg_type": pg}),
)
def test_a_decimal_field_follows_its_column(self):
"""Un `float` d'Odoo sur une colonne entière garde le plafond de
celle-ci ; ailleurs, aucun plafond ne s'applique."""
self.assertEqual(
"integer",
anon.type_de_coulee({"ttype": "float", "pg_type": "integer"}),
)
self.assertIsNone(
anon.type_de_coulee({"ttype": "float", "pg_type": "numeric"})
)
def test_no_ceiling_is_applied_to_an_unbounded_cast(self):
"""Borner à `integer` le haut d'une bande dont le bas le dépasse
INVERSE la bande, et chaque ligne lève."""
champ = {
"model": "m",
"name": "montant",
"ttype": "integer",
"pg_type": "numeric",
"unique": False,
"checked": False,
"max_len": None,
"borne_min": 1,
"borne_max": 10**12,
}
sql = anon.expression_calibre(champ)
self.assertNotIn("least(", sql)
self.assertNotIn("2147483647", sql)
self.assertIn("::numeric", sql)
def test_the_measured_extent_pours_the_same_way(self):
"""`expression_nombre` portait le même `::integer` en dur."""
champ = {
"model": "m",
"name": "montant",
"ttype": "integer",
"pg_type": "numeric",
"unique": False,
"checked": False,
"max_len": None,
"borne_min": 1,
"borne_max": 10**12,
}
sql = anon.expression_nombre(champ)
self.assertIn("::numeric", sql)
self.assertNotIn("::integer", sql)
class TestTheExitCodeContract(unittest.TestCase):
"""Les codes de sortie sont lus par le menu qui écrit.
Ils doivent se distinguer d'une TRACE PYTHON, qui sort en 1 : le flux
lisait « tout ce qui n'est ni 0 ni 2 » comme du travail annoncé, et
demandait la confirmation destructrice après un plantage.
`--apply` sur un plan vide rendait 0 lui aussi, psql acceptant un
script vide : l'appelant y lisait « écriture faite » et tirait une
sauvegarde de la base intacte en l'annonçant anonymisée.
"""
def setUp(self):
self.champs = []
for nom, remplacant in (
("require_odoo_database", lambda *a, **k: None),
):
self.addCleanup(
setattr, anon.lib_analyse, nom, getattr(anon.lib_analyse, nom)
)
setattr(anon.lib_analyse, nom, remplacant)
for nom, remplacant in (
("inspect", lambda *a, **k: self.champs),
("sonder_colonnes", lambda *a, **k: ({}, {})),
("ecrire", lambda *a, **k: None),
):
self.addCleanup(setattr, anon, nom, getattr(anon, nom))
setattr(anon, nom, remplacant)
sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = sortie
self.sortie = sortie
self.addCleanup(setattr, sys, "stdout", vrai)
def _f(self, nom, ttype="char", pg="character varying", unique=False):
return {
"model": "res.partner",
"name": nom,
"ttype": ttype,
"pg_type": pg,
"unique": unique,
"checked": False,
"max_len": None,
}
def _code(self, champs, applique=False):
self.champs = champs
extra = ["--apply", "--confirm", "b"] if applique else []
return anon.main(["--database", "b"] + extra)
def test_the_codes_do_not_collide_with_a_python_traceback(self):
"""Une exception non rattrapée sort en 1 : aucun code du contrat
ne doit valoir 1."""
codes = (
anon.SORTIE_RIEN,
anon.SORTIE_REFUS,
anon.SORTIE_A_FAIRE,
anon.SORTIE_SANS_EFFET,
)
self.assertNotIn(1, codes)
self.assertEqual(len(set(codes)), len(codes))
def test_an_empty_plan_announces_nothing_to_do(self):
self.assertEqual(anon.SORTIE_RIEN, self._code([]))
def test_real_work_announces_itself_with_its_OWN_code(self):
self.assertEqual(anon.SORTIE_A_FAIRE, self._code([self._f("name")]))
def test_a_warning_only_plan_announces_NO_work(self):
"""Le seul cas pour lequel l'avertissement existe : le compter
pour du travail ferait confirmer une écriture sans objet."""
champs = [self._f("numero", "integer", "integer", unique=True)]
self.assertEqual(anon.SORTIE_RIEN, self._code(champs))
self.assertIn(
anon.t("left in clear, numeric and unique:"),
self.sortie.getvalue(),
)
def test_apply_on_an_empty_plan_is_not_a_write(self):
self.assertEqual(anon.SORTIE_SANS_EFFET, self._code([], True))
def test_apply_on_a_warning_only_plan_is_not_a_write_either(self):
champs = [self._f("numero", "integer", "integer", unique=True)]
self.assertEqual(anon.SORTIE_SANS_EFFET, self._code(champs, True))
def test_apply_that_writes_returns_the_success_code(self):
self.assertEqual(anon.SORTIE_RIEN, self._code([self._f("name")], True))
def test_a_write_error_returns_the_refusal_code(self):
anon.ecrire = lambda *a, **k: "collision d'unicité"
self.assertEqual(
anon.SORTIE_REFUS, self._code([self._f("name")], True)
)
def test_a_confirm_that_does_not_repeat_the_name_refuses(self):
self.champs = [self._f("name")]
self.assertEqual(
anon.SORTIE_REFUS,
anon.main(["--database", "b", "--apply", "--confirm", "autre"]),
)
def test_no_empty_script_ever_reaches_psql(self):
"""`ecrire` recevait un script vide, que psql accepte."""
appels = []
anon.ecrire = lambda *a, **k: appels.append(a) or None
self._code([], True)
self._code(
[self._f("numero", "integer", "integer", unique=True)], True
)
self.assertEqual([], appels)
self._code([self._f("name")], True)
self.assertEqual(1, len(appels))
class TestWhichAbstentionGetsNamed(unittest.TestCase):
"""« Laquelle toucher » et « laquelle nommer » sont deux questions.
L'ordre des contrôles répond à la première : il rend UN motif, le
premier rencontré. Le réutiliser comme prédicat du rapport tait une
colonne numérique unique dès qu'un autre motif la précède — une
contrainte CHECK, un jsonb, un nom en `_id` — alors que c'est
exactement ce que la ligne ⚠ affirme.
Le cas qui compte : un module déclarant `unique(numero)` ET
`check(numero > 0)` sur un numéro de document ou d'employé. La
requête lève `checked` pour TOUTE contrainte sur un nombre, donc le
motif rendu est la contrainte, jamais l'unicité.
"""
MOTS = {"MOTS": ["aa", "bb"]}
def _f(self, nom, ttype="integer", pg="integer", **kw):
return {
"model": "x.y",
"name": nom,
"ttype": ttype,
"pg_type": pg,
"unique": kw.pop("unique", False),
"checked": kw.pop("checked", False),
"max_len": None,
}
def _nommes(self, champ):
"""Ce que le rapport nomme, la colonne étant accompagnée d'un
texte pour qu'une étape existe."""
etapes = anon.plan(
[champ, self._f("libelle", "char", "character varying")],
mode="blacklist",
mots=self.MOTS,
)
return etapes[0]["abstenus"] if etapes else []
def test_the_primary_key_stays_SILENT(self):
"""`id` est un entier unique sur CHAQUE modèle : le nommer
partout noyait les vrais avertissements."""
self.assertEqual([], self._nommes(self._f("id", unique=True)))
def test_every_unique_number_left_in_clear_IS_named(self):
for nom, kw in (
("numero", {"unique": True}),
("numero", {"unique": True, "checked": True}),
("numero", {"unique": True, "pg": "jsonb"}),
("compteur_id", {"unique": True}),
):
with self.subTest(nom=nom, **kw):
self.assertEqual([nom], self._nommes(self._f(nom, **kw)))
def test_the_reason_returned_is_NOT_the_uniqueness_one(self):
"""La preuve que le prédicat ne peut pas s'y adosser."""
for kw in (
{"unique": True, "checked": True},
{"unique": True, "pg": "jsonb"},
):
with self.subTest(**kw):
champ = self._f("numero", **kw)
self.assertNotEqual(
anon.REFUS_NOMBRE_UNIQUE, anon.raison_du_refus(champ)
)
self.assertTrue(
anon.abstention_a_nommer(
champ, anon.raison_du_refus(champ)
)
)
def test_a_unique_TEXT_column_is_not_named(self):
"""Le texte a une issue : `expression_texte` y colle l'id."""
champ = self._f("ref", "char", "character varying", unique=True)
self.assertEqual([], self._nommes(champ))
def test_a_number_that_is_not_unique_is_not_named(self):
self.assertEqual([], self._nommes(self._f("montant")))
def test_a_column_that_is_TAKEN_is_not_named(self):
self.assertFalse(anon.abstention_a_nommer(self._f("montant"), None))
def test_the_floor_is_the_only_reason_that_silences(self):
"""Toute autre raison laisse la question au type et à l'unicité."""
champ = self._f("numero", unique=True)
for raison in (
anon.REFUS_RELATION,
anon.REFUS_CONTRAINTE,
anon.REFUS_JSONB_NOMBRE,
anon.REFUS_NOMBRE_UNIQUE,
anon.REFUS_STRUCTURE,
anon.REFUS_CONNEXION,
):
with self.subTest(raison=raison):
self.assertTrue(anon.abstention_a_nommer(champ, raison))
self.assertFalse(anon.abstention_a_nommer(champ, anon.REFUS_PLANCHER))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -2,14 +2,19 @@
# © 2026 TechnoLibre (http://www.technolibre.ca) # © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import ast
import builtins
import io
import json import json
import os import os
import subprocess import subprocess
import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch from unittest.mock import MagicMock, mock_open, patch
from script.todo import todo_i18n
from script.todo.todo import ( from script.todo.todo import (
ANDROID_DIR, ANDROID_DIR,
CONFIG_FILE, CONFIG_FILE,
@ -806,5 +811,293 @@ class TestModuleLevelAbortExit(unittest.TestCase):
self.assertNotIn("Traceback", result.stderr) self.assertNotIn("Traceback", result.stderr)
class TestOptionsDeLAnonymiseur(unittest.TestCase):
"""Le mode, les listes, et les deux options qui suivent.
La garde de la liste blanche appartient à la question des MODÈLES :
c'est le `elif` de son `if`. Une question insérée entre les deux la
rattache à la dernière posée, si bien qu'elle refuse selon une
réponse qui n'est pas la sienne et laisse passer une liste blanche
vide — laquelle n'anonymise rien, en l'annonçant comme un succès.
Les cinq chemins du mode sont couverts ici, plus le fichier de mots
introuvable.
"""
def setUp(self):
from script.todo.todo import TODO
self.todo = TODO.__new__(TODO)
self.vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", self.vrai_input)
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
def _repondre(self, mode, *reponses):
import script.todo.todo as module
suite = iter(reponses)
builtins.input = lambda invite="": next(suite)
vrai = module.click.prompt
module.click.prompt = lambda *a, **k: mode
self.addCleanup(setattr, module.click, "prompt", vrai)
return self.todo._monitoring_anonymize_options()
def test_une_liste_blanche_sans_modele_est_REFUSEE(self):
"""Elle ne ferait rien : le dire vaut mieux que la lancer."""
self.assertIsNone(self._repondre("2", "", "n", "n", ""))
def test_une_liste_blanche_avec_un_modele_passe(self):
self.assertEqual(
self._repondre("2", "res.partner", "n", "n", ""),
["--mode", "whitelist", "--models", "res.partner"],
)
def test_l_hybride_sans_modele_passe(self):
"""Ses défauts SONT sa liste : rien à nommer."""
self.assertEqual(
self._repondre("1", "", "n", "n", ""), ["--mode", "hybrid"]
)
def test_les_deux_options_suivent_le_mode(self):
self.assertEqual(
self._repondre("1", "", "o", "o", ""),
["--mode", "hybrid", "--include-logins", "--keep-digits"],
)
def test_la_liste_noire_exclut(self):
self.assertEqual(
self._repondre("3", "res.users", "n", "n", ""),
["--mode", "blacklist", "--exclude", "res.users"],
)
def test_un_mode_inconnu_renonce(self):
self.assertIsNone(self._repondre("9"))
def test_un_fichier_de_mots_introuvable_est_REFUSE(self):
"""Le lancer produirait une trace au lieu d'un message."""
self.assertIsNone(
self._repondre("1", "", "n", "n", "/nexistepas/mots.py")
)
class TestAttributsDeTODO(unittest.TestCase):
"""Tout `self.X` que `TODO` LIT est-il posé par TODO ou un mixin ?
Un attribut mal orthographié ne lève qu'à l'exécution de l'entrée qui
le touche : `self._execute` là où `TODO` pose `self.execute` traverse
tout contrôle statique et attend l'opérateur.
Le nom se cherche dans les classes dont TODO HÉRITE, et nulle part
ailleurs : `DatabaseManager` pose bien `_execute`, si bien que le
chercher dans tout `script/todo/` le trouve et ne voit rien. Un
bouchon de test qui fournit l'attribut masque la faute de la même
façon.
"""
RACINE = Path(__file__).resolve().parents[1] / "script" / "todo"
@staticmethod
def _noms_de_classe(classe):
"""Méthodes et attributs que cette classe POSE."""
noms = set()
for noeud in ast.walk(classe):
if isinstance(noeud, (ast.FunctionDef, ast.AsyncFunctionDef)):
noms.add(noeud.name)
if (
isinstance(noeud, ast.Attribute)
and isinstance(noeud.value, ast.Name)
and noeud.value.id == "self"
and isinstance(noeud.ctx, ast.Store)
):
noms.add(noeud.attr)
for corps in classe.body:
if isinstance(corps, ast.Assign):
for cible in corps.targets:
if isinstance(cible, ast.Name):
noms.add(cible.id)
return noms
def _classe_todo(self):
source = (self.RACINE / "todo.py").read_text(encoding="utf-8")
arbre = ast.parse(source)
return next(
n
for n in ast.walk(arbre)
if isinstance(n, ast.ClassDef) and n.name == "TODO"
)
def test_aucun_attribut_lu_sans_etre_pose(self):
todo = self._classe_todo()
bases = {b.id for b in todo.bases if isinstance(b, ast.Name)}
self.assertGreater(len(bases), 5, "TODO est composé de mixins")
poses = self._noms_de_classe(todo)
trouvees = set()
for chemin in sorted(self.RACINE.rglob("*.py")):
try:
arbre = ast.parse(chemin.read_text(encoding="utf-8"))
except SyntaxError: # pragma: no cover - fichier en travaux
continue
for noeud in ast.walk(arbre):
if isinstance(noeud, ast.ClassDef) and noeud.name in bases:
poses |= self._noms_de_classe(noeud)
trouvees.add(noeud.name)
# Une base introuvable rendrait le contrôle muet : ses noms
# manqueraient, et tout ce qu'elle pose passerait pour orphelin.
self.assertEqual(bases - trouvees, set(), "base introuvable")
lus = {
noeud.attr
for noeud in ast.walk(todo)
if isinstance(noeud, ast.Attribute)
and isinstance(noeud.value, ast.Name)
and noeud.value.id == "self"
and isinstance(noeud.ctx, ast.Load)
}
self.assertEqual(sorted(lus - poses), [])
class TestLeFluxQuiEcrit(unittest.TestCase):
"""`_monitoring_write_flow` : les codes de sortie sont un CONTRAT.
La marche à blanc rend 2 pour un refus, 1 quand il y a du travail, et
0 quand il n'y a RIEN à anonymiser. Ne distinguer que le 2 fait
confirmer puis « appliquer » un plan vide : l'appelant tire alors une
sauvegarde de la base INTACTE et l'annonce anonymisée.
"""
def setUp(self):
from script.analyse import monitoring
self.todo = TODO.__new__(TODO)
self.todo._monitoring_anonymize_options = lambda: ["--mode", "hybrid"]
self.monitoring = monitoring
self.addCleanup(
setattr, monitoring, "run_analysis", monitoring.run_analysis
)
self.addCleanup(setattr, builtins, "input", builtins.input)
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
self.appels = []
def _codes(self, *codes):
"""Les codes que la marche à blanc puis l'écriture rendront."""
suite = iter(codes)
def faux(analyse, database, **kw):
self.appels.append(list(kw.get("extra") or []))
return next(suite)
self.monitoring.run_analysis = faux
def _taper(self, *reponses):
suite = iter(reponses)
builtins.input = lambda invite="": next(suite)
def _dit(self, cle):
return todo_i18n.t(cle) in self.sortie.getvalue()
def test_un_plan_vide_ne_se_fait_pas_confirmer(self):
"""Retaper le nom d'une base qu'on ne touchera pas obtient un
consentement sans objet."""
self._codes(0)
self._taper() # aucune invite ne doit être posée
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
self.assertEqual(1, len(self.appels))
def test_un_plan_vide_le_DIT(self):
self._codes(0)
self.todo._monitoring_write_flow({}, "base")
self.assertTrue(self._dit("Nothing to anonymise: nothing to confirm."))
def test_un_refus_de_la_marche_a_blanc_arrete(self):
self._codes(2)
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
self.assertEqual(1, len(self.appels))
def test_du_travail_annonce_demande_le_nom_puis_ecrit(self):
self._codes(3, 0)
self._taper("base")
self.assertIs(True, self.todo._monitoring_write_flow({}, "base"))
self.assertEqual(2, len(self.appels))
self.assertIn("--apply", self.appels[1])
self.assertIn("--confirm", self.appels[1])
def test_un_nom_mal_retape_n_ecrit_rien(self):
self._codes(3)
self._taper("bas")
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
self.assertEqual(1, len(self.appels))
def test_une_ecriture_en_erreur_rend_FAUX(self):
"""L'appelant ne doit pas tirer de sauvegarde derrière."""
self._codes(3, 2)
self._taper("base")
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
def test_renoncer_aux_options_n_appelle_rien(self):
self.todo._monitoring_anonymize_options = lambda: None
self._codes()
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
self.assertEqual([], self.appels)
def test_seul_le_code_du_TRAVAIL_ouvre_la_confirmation(self):
"""Une trace Python sort en 1. Lire « ni 0 ni 2 » comme du travail
faisait demander la confirmation destructrice après un plantage,
puis « appliquer » un plan jamais calculé."""
for code in (0, 1, 2, 4, 5, 127):
with self.subTest(code=code):
self._codes(code)
self.appels = []
self._taper() # aucune invite ne doit être posée
self.assertIs(
False, self.todo._monitoring_write_flow({}, "base")
)
self.assertEqual(1, len(self.appels))
def test_un_code_inattendu_le_DIT(self):
"""Le refus, lui, a déjà parlé : ne pas le redoubler."""
self._codes(1)
self.todo._monitoring_write_flow({}, "base")
self.assertTrue(self._dit("The dry run ended on an unexpected code:"))
self.sortie.truncate(0)
self.sortie.seek(0)
self._codes(2)
self.appels = []
self.todo._monitoring_write_flow({}, "base")
self.assertFalse(self._dit("The dry run ended on an unexpected code:"))
def test_une_ecriture_SANS_EFFET_ne_vaut_pas_une_ecriture(self):
"""Un plan devenu vide entre les deux passes rendait 0, psql
acceptant un script vide : l'appelant tirait alors une sauvegarde
de la base intacte en l'annonçant anonymisée."""
from script.analyse import anonymize
self._codes(3, anonymize.SORTIE_SANS_EFFET)
self._taper("base")
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
def test_les_codes_sont_ceux_que_le_moteur_declare(self):
"""Le contrat vit dans `anonymize`, pas en double ici."""
from script.analyse import anonymize
self.assertEqual(0, anonymize.SORTIE_RIEN)
self.assertEqual(2, anonymize.SORTIE_REFUS)
self.assertEqual(3, anonymize.SORTIE_A_FAIRE)
self.assertEqual(4, anonymize.SORTIE_SANS_EFFET)
self.assertNotIn(
1,
(
anonymize.SORTIE_RIEN,
anonymize.SORTIE_REFUS,
anonymize.SORTIE_A_FAIRE,
anonymize.SORTIE_SANS_EFFET,
),
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -97,6 +97,7 @@ class TestExecuteMenuNumbering(unittest.TestCase):
"Process": "prompt_execute_process", "Process": "prompt_execute_process",
"Database": "prompt_execute_database", "Database": "prompt_execute_database",
"Analyse": "prompt_execute_analyse", "Analyse": "prompt_execute_analyse",
"Transform data": "prompt_execute_transform",
"Git": "prompt_execute_git", "Git": "prompt_execute_git",
"Doc": "prompt_execute_doc", "Doc": "prompt_execute_doc",
"GPT code": "prompt_execute_gpt_code", "GPT code": "prompt_execute_gpt_code",

View file

@ -0,0 +1,407 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""« Anonymiser une base Odoo ou une sauvegarde » : le DIALOGUE.
Ce qui est éprouvé ici est l'ORCHESTRATION, non PostgreSQL : quelle
provenance mène à quoi, dans quel ordre les trois gestes s'enchaînent —
restaurer, anonymiser, réexporter — et ce qu'un refus n'appelle pas.
`anonymize.py` a ses propres 79 tests, `_monitoring_write_flow` son
dialogue de confirmation. Les rejouer ici ferait deux endroits pour la
même règle. Ce fichier bouchonne donc tout ce qui touche le serveur, et
ne garde que les décisions du menu.
"""
import builtins
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from script.todo import todo_i18n # noqa: E402
def _tm():
"""Le module du menu, importé au premier appel."""
from script.todo import transform_menu
return transform_menu
class _Execute:
"""`exec_command_live` bouchonné, et la trace des commandes."""
def __init__(self, statut=0, sortie=()):
self.commandes = []
self.statut = statut
self.sortie = list(sortie)
def exec_command_live(self, commande, **_kw):
self.commandes.append(commande)
return self.statut, self.sortie
class _Bases:
"""Le gestionnaire de bases, réduit à ce que l'entrée lui demande."""
def __init__(self, base="", zip_local="", distant=(1, "", "")):
self.base = base
self.zip_local = zip_local
self.distant = distant
self.appels = []
def select_database(self):
self.appels.append("select_database")
return self.base
def select_backup_path(self, start=None):
self.appels.append("select_backup_path")
return self.zip_local
def download_database_backup_cli(self):
self.appels.append("download")
return self.distant
def _menu(**kw):
"""Le mixin, monté sur les aides que `TODO` lui fournit.
Les aides d'oui/non sont PRISES sur la vraie classe : « o », « oui »,
« y » et la différence entre un défaut oui et un défaut non sont sa
règle, et une copie dériverait sans qu'un test le voie.
"""
from script.todo.todo import TODO
class Bouchon(_tm().TransformMenuMixin):
_is_yes = staticmethod(TODO._is_yes)
_is_yes_default_yes = staticmethod(TODO._is_yes_default_yes)
def __init__(self):
self.execute = kw.get("execute") or _Execute()
self.db_manager = kw.get("db_manager") or _Bases()
self.restaurations = []
self.flots = []
# Les deux emprunts à `TODO`, bouchonnés : ce qu'ils font est
# éprouvé chez eux, ce qui compte ici est QUAND ils sont appelés.
def _monitoring_restore(self, chemin):
self.restaurations.append(chemin)
return kw.get("restaure", "base_restauree")
def _monitoring_write_flow(self, analyse, base):
self.flots.append((analyse["key"], base))
return kw.get("ecrit", True)
return Bouchon()
class _Entrees:
"""`input()` bouchonné. Une question sans réponse LÈVE."""
def __init__(self, *reponses):
self.reponses = list(reponses)
self.demandes = []
def __call__(self, invite=""):
self.demandes.append(invite)
if not self.reponses:
raise AssertionError(
"question sans réponse : %r après %d"
% (invite, len(self.demandes) - 1)
)
return self.reponses.pop(0)
class BaseDialogue(unittest.TestCase):
"""De quoi piloter le dialogue sans terminal."""
def setUp(self):
self.base = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.base, True)
self.sortie = io.StringIO()
vrai = sys.stdout
sys.stdout = self.sortie
self.addCleanup(setattr, sys, "stdout", vrai)
self.vrai_input = builtins.input
self.addCleanup(setattr, builtins, "input", self.vrai_input)
# Le registre vit sous `private/transform/` : le détourner évite
# d'écrire dans le dépôt en lançant les tests.
self.vraie_sortie = _tm().SORTIE_PAR_DEFAUT
_tm().SORTIE_PAR_DEFAUT = os.path.join(self.base, "transform")
self.addCleanup(setattr, _tm(), "SORTIE_PAR_DEFAUT", self.vraie_sortie)
def _repondre(self, *reponses):
entrees = _Entrees(*reponses)
builtins.input = entrees
return entrees
def _prompt(self, *reponses):
"""`click.prompt` bouchonné : le choix de la provenance."""
restes = list(reponses)
vrai = _tm().click.prompt
_tm().click.prompt = lambda *a, **k: restes.pop(0)
self.addCleanup(setattr, _tm().click, "prompt", vrai)
class TestProvenance(BaseDialogue):
"""Quelle provenance mène à quoi."""
def test_une_base_locale_ne_passe_PAS_par_une_restauration(self):
"""La base même est modifiée : rien à restaurer."""
menu = _menu(db_manager=_Bases(base="prod_copie"))
self._prompt("1")
self.assertEqual(
menu._transform_anonymise_source(), ("prod_copie", False)
)
self.assertEqual(menu.restaurations, [])
def test_un_zip_local_passe_par_la_restauration(self):
menu = _menu(
db_manager=_Bases(zip_local="/tmp/sauvegarde.zip"),
restaure="sauvegarde_neutralize",
)
self._prompt("2")
self.assertEqual(
menu._transform_anonymise_source(),
("sauvegarde_neutralize", True),
)
self.assertEqual(menu.restaurations, ["/tmp/sauvegarde.zip"])
def test_un_telechargement_qui_ne_rend_rien_ne_restaure_pas(self):
"""Le refus vient AVANT la restauration : restaurer un fichier
absent produirait une trace au lieu d'un message."""
menu = _menu(db_manager=_Bases(distant=(0, "", "")))
self._prompt("3")
self.assertIsNone(menu._transform_anonymise_source())
self.assertEqual(menu.restaurations, [])
def test_zero_renonce(self):
menu = _menu()
self._prompt("0")
self.assertIsNone(menu._transform_anonymise_source())
def test_renoncer_au_choix_de_la_base_renonce_tout(self):
menu = _menu(db_manager=_Bases(base=""))
self._prompt("1")
self.assertIsNone(menu._transform_anonymise_source())
class TestOrdreDesGestes(BaseDialogue):
"""Restaurer, anonymiser, réexporter — dans cet ordre et pas un autre."""
def test_un_zip_est_restaure_PUIS_anonymise_PUIS_reexporte(self):
execute = _Execute()
menu = _menu(
execute=execute,
db_manager=_Bases(zip_local="/tmp/s.zip"),
restaure="s_neutralize",
)
self._prompt("2")
self._repondre("") # le nom de la sauvegarde : le défaut
menu._transform_anonymise_base()
self.assertEqual(menu.restaurations, ["/tmp/s.zip"])
self.assertEqual(menu.flots, [("anonymize", "s_neutralize")])
self.assertEqual(len(execute.commandes), 1)
self.assertIn("--backup", execute.commandes[0])
self.assertIn("s_neutralize", execute.commandes[0])
def test_une_base_locale_n_est_PAS_reexportee(self):
"""La base même est le résultat : il n'y a pas de zip à écrire."""
execute = _Execute()
menu = _menu(execute=execute, db_manager=_Bases(base="prod_copie"))
self._prompt("1")
menu._transform_anonymise_base()
self.assertEqual(menu.flots, [("anonymize", "prod_copie")])
self.assertEqual(execute.commandes, [])
def test_une_base_locale_est_AVERTIE_avant_le_travail(self):
menu = _menu(db_manager=_Bases(base="prod_copie"))
self._prompt("1")
menu._transform_anonymise_base()
self.assertIn(
todo_i18n.t("The database ITSELF is modified; no copy."),
self.sortie.getvalue(),
)
def test_un_renoncement_ne_tire_AUCUNE_sauvegarde(self):
"""C'était le pire des deux mondes : l'opérateur croyait tenir
une copie transmissible et tenait l'original.
`_monitoring_write_flow` rend faux quand rien n'a été écrit — mode
refusé, marche à blanc en erreur, nom mal retapé — et la
sauvegarde ne se tire que sur un vrai.
"""
execute = _Execute()
menu = _menu(
execute=execute,
db_manager=_Bases(zip_local="/tmp/s.zip"),
restaure="s_neutralize",
ecrit=False,
)
self._prompt("2")
menu._transform_anonymise_base()
self.assertEqual(menu.flots, [("anonymize", "s_neutralize")])
self.assertEqual(execute.commandes, [])
rendu = self.sortie.getvalue()
self.assertIn(
todo_i18n.t("Nothing was written; no backup was drawn."), rendu
)
self.assertIn(todo_i18n.t("Database kept: "), rendu)
def test_la_base_restauree_est_INSCRITE_meme_sur_un_renoncement(self):
"""Elle existe sur le serveur : c'est précisément celle-là qu'il
faut pouvoir retrouver."""
menu = _menu(
db_manager=_Bases(zip_local="/tmp/s.zip"),
restaure="s_neutralize",
ecrit=False,
)
self._prompt("2")
menu._transform_anonymise_base()
self.assertEqual(
[b["base"] for b in menu._transform_bases_lues()],
["s_neutralize"],
)
def test_renoncer_a_la_provenance_n_appelle_RIEN(self):
execute = _Execute()
menu = _menu(execute=execute)
self._prompt("0")
menu._transform_anonymise_base()
self.assertEqual(menu.flots, [])
self.assertEqual(execute.commandes, [])
class TestExportDuZip(BaseDialogue):
"""Le zip de sortie : son nom, et ce qui n'est jamais écrasé."""
def test_le_defaut_porte_le_nom_de_la_base_et_l_horodatage(self):
execute = _Execute()
menu = _menu(execute=execute)
self._repondre("")
menu._transform_export_zip("s_neutralize")
(commande,) = execute.commandes
self.assertIn("--database s_neutralize", commande)
self.assertIn("s_neutralize_anon_", commande)
def test_un_nom_tape_l_emporte(self):
execute = _Execute()
menu = _menu(execute=execute)
self._repondre("livraison_2026")
menu._transform_export_zip("s")
self.assertIn("--restore_image livraison_2026", execute.commandes[0])
def test_le_suffixe_zip_tape_n_est_pas_doublé(self):
"""`--restore_image` prend un nom, non un chemin : « .zip » y
ferait un fichier « .zip.zip »."""
execute = _Execute()
menu = _menu(execute=execute)
self._repondre("livraison.zip")
menu._transform_export_zip("s")
self.assertIn("--restore_image livraison ", execute.commandes[0] + " ")
def test_un_echec_de_la_sauvegarde_est_DIT(self):
execute = _Execute(statut=1)
menu = _menu(execute=execute)
self._repondre("")
menu._transform_export_zip("s")
self.assertNotIn(
todo_i18n.t("Backup written: "), self.sortie.getvalue()
)
def test_le_bilan_dit_les_trois_choses(self):
execute = _Execute()
menu = _menu(execute=execute)
self._repondre("")
menu._transform_export_zip("s_neutralize")
rendu = self.sortie.getvalue()
self.assertIn(todo_i18n.t("Backup written: "), rendu)
self.assertIn(todo_i18n.t("The original was not modified."), rendu)
self.assertIn(todo_i18n.t("Database kept: "), rendu)
class TestRegistreDesBases(BaseDialogue):
"""Le serveur ne dit pas QUI a créé une base : le registre le dit."""
def test_une_base_produite_est_inscrite(self):
menu = _menu()
menu._transform_noter_base("s_neutralize", True)
(inscrite,) = menu._transform_bases_lues()
self.assertEqual(inscrite["base"], "s_neutralize")
self.assertTrue(inscrite["depuis_zip"])
self.assertTrue(inscrite["date"])
def test_deux_passages_sur_la_meme_base_n_en_font_qu_une(self):
menu = _menu()
menu._transform_noter_base("s", True)
menu._transform_noter_base("s", False)
inscrites = menu._transform_bases_lues()
self.assertEqual(len(inscrites), 1)
self.assertFalse(inscrites[0]["depuis_zip"])
def test_un_registre_ABIME_ne_fait_pas_lever(self):
"""Il est un confort, comme la mémoire de lot : le perdre vaut
mieux que perdre le travail."""
menu = _menu()
os.makedirs(_tm().SORTIE_PAR_DEFAUT, exist_ok=True)
chemin = os.path.join(_tm().SORTIE_PAR_DEFAUT, "bases.json")
for contenu in ('{"pas": "une liste"', "[1, 2, 3]", ""):
with self.subTest(contenu=contenu):
with open(chemin, "w", encoding="utf-8") as flux:
flux.write(contenu)
self.assertEqual(menu._transform_bases_lues(), [])
menu._transform_noter_base("s", True)
def test_un_registre_absent_rend_une_liste_vide(self):
self.assertEqual(_menu()._transform_bases_lues(), [])
class TestBasesProduites(BaseDialogue):
"""L'entrée qui les liste."""
def _peupler(self, menu, *noms):
for nom in noms:
menu._transform_noter_base(nom, True)
def test_un_registre_vide_le_dit(self):
_menu()._transform_bases_produites()
self.assertIn(todo_i18n.t("Empty file."), self.sortie.getvalue())
def test_une_base_VIVANTE_est_listee(self):
menu = _menu(execute=_Execute(sortie=["s_neutralize", "autre"]))
self._peupler(menu, "s_neutralize")
menu._transform_bases_produites()
self.assertIn("s_neutralize", self.sortie.getvalue())
def test_une_base_DISPARUE_est_dite_a_part(self):
"""Détruite ailleurs — par Database, par un db_drop_all — elle ne
doit pas être proposée à l'effacement."""
menu = _menu(execute=_Execute(sortie=["autre"]))
self._peupler(menu, "s_neutralize")
menu._transform_bases_produites()
rendu = self.sortie.getvalue()
self.assertIn(
todo_i18n.t("Gone already, only in the register:"), rendu
)
def test_un_serveur_injoignable_ne_declare_RIEN_disparu(self):
"""Une liste vide et une ignorance ne se confondent pas : sans
serveur, tout le registre paraîtrait détruit."""
menu = _menu(execute=_Execute(statut=1))
self._peupler(menu, "s_neutralize")
menu._transform_bases_produites()
rendu = self.sortie.getvalue()
self.assertIn(
todo_i18n.t("Gone already, only in the register:"), rendu
)
self.assertNotIn(todo_i18n.t("Use Database to drop one."), rendu)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load diff

708
test/test_transform_form.py Normal file
View file

@ -0,0 +1,708 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'écran de périmètre de « Transform data ».
Un fichier À PART, et non une classe de plus dans
`test_transform_external.py` : celui-là s'importe sous DEUX interpréteurs
— celui du CLI, et le venv dédié qui bâtit les fixtures de classeur et
n'a ni `click` ni `textual`. Y mettre un test d'écran casserait la moitié
de ce fichier sur un `ModuleNotFoundError` sans rapport avec ce qu'il
éprouve.
Deux niveaux de preuve :
- les fonctions PURES et la structure de l'app, sans monter un widget ;
- un pilotage RÉEL par `run_test()`, qui seul prouve que l'écran répond
aux touches. Le reste peut passer sur un écran qui ne s'affiche pas.
"""
import asyncio
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from script.todo import transform_form # noqa: E402
try:
import textual # noqa: F401
TEXTUAL = True
except ImportError: # pragma: no cover - textual peut manquer
TEXTUAL = False
SANS_TEXTUAL = (
"textual est absent de cet interpréteur : installer les dépendances du"
" CLI, ou lancer ce fichier sous .venv.erplibre"
)
def rapport():
"""Un rapport de moteur, réduit à ce que l'écran en lit.
Trois colonnes qui posent chacune un cas : une étiquetée, une SANS
libellé — le cas de la majorité des colonnes d'un export réel —, et
une planchéiée, qu'aucune réponse ne doit pouvoir cocher.
"""
return {
"chemin": "/tmp/classeur.xlsx",
"format": "xlsx",
"feuilles": [
{
"nom": "Ventes",
"lignes": 4,
"colonnes_n": 3,
"lignes_entete": [1],
"ligne_champs": 1,
"entete_declaree": False,
"colonnes": [
{
"index": 1,
"etiquette": "montant",
"type": "nombre",
"remplies": 3,
"distinctes": 3,
"exemples": ["12", "13", "14"],
"plancher": False,
},
{
"index": 2,
"etiquette": "",
"type": "texte",
"remplies": 3,
"distinctes": 3,
"exemples": ["aboulie", "acai", "adobe"],
"plancher": False,
},
{
"index": 3,
"etiquette": "partner_id",
"type": "texte",
"remplies": 3,
"distinctes": 3,
"exemples": ["base.p1"],
"plancher": True,
},
],
"lignes_sondees": [
{
"numero": 1,
"apercu": ["montant", "", "partner_id"],
"pleines": 2,
"mesure": {
"contraste": 0.5,
"hors_colonne": 1.0,
"accord": 0.0,
},
},
{
"numero": 2,
"apercu": ["12", "aboulie", "base.p1"],
"pleines": 3,
"mesure": {
"contraste": 0.0,
"hors_colonne": 0.3,
"accord": 1.0,
},
},
],
},
{
"nom": "Achats",
"lignes": 2,
"colonnes_n": 1,
"lignes_entete": [],
"ligne_champs": None,
"entete_declaree": False,
"colonnes": [
{
"index": 1,
"etiquette": "",
"type": "texte",
"remplies": 2,
"distinctes": 2,
"exemples": ["acanthe", "adelphique"],
"plancher": False,
}
],
"lignes_sondees": [
{
"numero": 1,
"apercu": ["acanthe"],
"pleines": 1,
"mesure": None,
}
],
},
],
}
class TestContexte(unittest.TestCase):
"""Le contexte est de la donnée PURE, tirée du rapport.
C'est ce qui garantit que l'écran n'ouvre rien lui-même : aucune
entrée-sortie et aucun sous-processus depuis l'affichage.
"""
def setUp(self):
self.ctx = transform_form.contexte_depuis_rapport(rapport())
def test_chaque_feuille_traverse(self):
self.assertEqual(
[f["nom"] for f in self.ctx["feuilles"]], ["Ventes", "Achats"]
)
def test_l_empan_mesure_traverse(self):
self.assertEqual(self.ctx["feuilles"][0]["lignes_entete"], [1])
self.assertEqual(self.ctx["feuilles"][1]["lignes_entete"], [])
self.assertIsNone(self.ctx["feuilles"][1]["ligne_champs"])
def test_un_rapport_vide_ne_leve_pas(self):
vide = transform_form.contexte_depuis_rapport({})
self.assertEqual(vide["feuilles"], [])
self.assertEqual(vide["fichier"], "")
def test_une_feuille_sans_colonne_ni_sondee_ne_leve_pas(self):
ctx = transform_form.contexte_depuis_rapport(
{"feuilles": [{"nom": "F"}]}
)
feuille = ctx["feuilles"][0]
self.assertEqual(feuille["colonnes"], [])
self.assertEqual(feuille["lignes_sondees"], [])
class TestMemoireDansLeContexte(unittest.TestCase):
"""Ce qu'une table de lot se rappelle arrive PRÉ-COCHÉ et MARQUÉ.
Jamais appliqué en silence : une réponse fausse appliquée sans être
vue est exactement comment une erreur gagne tout un lot.
"""
def test_sans_memoire_l_empan_est_celui_de_la_mesure(self):
ctx = transform_form.contexte_depuis_rapport(rapport())
self.assertEqual(ctx["feuilles"][0]["lignes_entete"], [1])
self.assertFalse(ctx["feuilles"][0]["entete_memorisee"])
def test_la_memoire_remplace_l_empan_et_se_MARQUE(self):
ctx = transform_form.contexte_depuis_rapport(
rapport(), {"Achats": [1, 2]}
)
achats = ctx["feuilles"][1]
self.assertEqual(achats["lignes_entete"], [1, 2])
self.assertTrue(achats["entete_memorisee"])
def test_une_memoire_VIDE_veut_dire_pas_d_en_tete(self):
"""Et se distingue d'une absence de mémoire : l'une répond « pas
d'en-tête », l'autre laisse la mesure trancher."""
ctx = transform_form.contexte_depuis_rapport(rapport(), {"Ventes": []})
ventes = ctx["feuilles"][0]
self.assertEqual(ventes["lignes_entete"], [])
self.assertTrue(ventes["entete_memorisee"])
def test_une_feuille_hors_memoire_garde_sa_mesure(self):
ctx = transform_form.contexte_depuis_rapport(
rapport(), {"Achats": [1]}
)
self.assertEqual(ctx["feuilles"][0]["lignes_entete"], [1])
self.assertFalse(ctx["feuilles"][0]["entete_memorisee"])
def test_des_lignes_en_chaines_sont_normalisees(self):
"""Elles arrivent d'un JSON relu à la main."""
ctx = transform_form.contexte_depuis_rapport(
rapport(), {"Achats": ["2", "1", "1"]}
)
self.assertEqual(ctx["feuilles"][1]["lignes_entete"], [1, 2])
@unittest.skipUnless(TEXTUAL, SANS_TEXTUAL)
class TestMemoireALEcran(unittest.TestCase):
"""L'écran part de la mémoire, et la montre."""
def test_l_empan_de_depart_vient_de_la_memoire(self):
app = transform_form.run_transform_form(
transform_form.contexte_depuis_rapport(
rapport(), {"Achats": [1, 2]}
),
run_app=False,
)
self.assertEqual(app._entetes["Achats"], {1, 2})
self.assertEqual(app._entetes["Ventes"], {1})
class TestLegende(unittest.TestCase):
"""La légende explique CHAQUE marque que l'écran peut poser.
Le test lisait sa propre copie du littéral : une marque ajoutée sans
être expliquée passait au vert. Il lit désormais la même constante que
`compose`, et n'ouvre aucun widget — donc rien à ignorer si textual
manque.
"""
MARQUES = (
"MARQUE_INTACTE",
"MARQUE_PLANCHER",
"MARQUE_CORRIGEE",
"MARQUE_DU_LOT",
)
def test_chaque_marque_figure_dans_la_legende(self):
from script.todo.todo_i18n import t as traduire
legende = traduire(transform_form.TEXTE_LEGENDE)
for nom in self.MARQUES:
with self.subTest(marque=nom):
self.assertIn(getattr(transform_form, nom), legende)
def test_la_marque_EN_PORTEE_n_a_pas_a_y_figurer(self):
"""C'est la case vide, le défaut : rien à expliquer."""
from script.todo.todo_i18n import t as traduire
self.assertNotIn(
transform_form.MARQUE_EN_PORTEE,
traduire(transform_form.TEXTE_LEGENDE),
)
def test_la_legende_est_traduite(self):
from script.todo.todo_i18n import TRANSLATIONS
self.assertIn(transform_form.TEXTE_LEGENDE, TRANSLATIONS)
class TestLibelles(unittest.TestCase):
"""Ce que l'écran montre d'une colonne et d'une ligne."""
def setUp(self):
self.ctx = transform_form.contexte_depuis_rapport(rapport())
self.colonnes = self.ctx["feuilles"][0]["colonnes"]
def test_une_colonne_sans_libelle_le_DIT(self):
"""Un blanc se lit comme une erreur d'affichage plutôt que comme
un fait, et c'est le cas de la majorité des colonnes."""
cellules = transform_form.libelle_de_colonne(
self.colonnes[1], transform_form.MARQUE_EN_PORTEE
)
self.assertTrue(cellules[2].strip())
self.assertNotEqual(cellules[2], "")
def test_les_exemples_sont_la_derniere_cellule(self):
"""CE qui distingue deux colonnes sans libellé : ni le type, ni
le compte, ni les bornes n'y suffisent."""
cellules = transform_form.libelle_de_colonne(
self.colonnes[1], transform_form.MARQUE_EN_PORTEE
)
self.assertIn("aboulie", cellules[-1])
self.assertIn("acai", cellules[-1])
def test_toutes_les_cellules_sont_des_chaines(self):
for colonne in self.colonnes:
with self.subTest(colonne=colonne["index"]):
for cellule in transform_form.libelle_de_colonne(
colonne, transform_form.MARQUE_EN_PORTEE
):
self.assertIsInstance(cellule, str)
def test_une_ligne_sondee_montre_ses_MESURES(self):
"""L'opérateur voit POURQUOI la mesure a tranché avant de la
contredire — contredire un verdict qu'on ne voit pas est un
pari."""
sondee = self.ctx["feuilles"][0]["lignes_sondees"][0]
cellules = transform_form.libelle_de_ligne_sondee(sondee, True)
self.assertEqual(cellules[0], transform_form.MARQUE_INTACTE)
self.assertIn("1.00", cellules[-1])
def test_une_ligne_sans_mesure_ne_montre_pas_de_zeros(self):
"""Rien à comparer n'est pas « toutes les mesures à zéro »."""
sondee = self.ctx["feuilles"][1]["lignes_sondees"][0]
cellules = transform_form.libelle_de_ligne_sondee(sondee, False)
self.assertEqual(cellules[-1], "")
class TestCleDeColonne(unittest.TestCase):
"""Une réponse d'ÉCRAN désigne une colonne par son index.
Pas par son étiquette : corriger l'empan d'en-tête renomme les
colonnes — la ligne de champs change — et une réponse portée par
l'étiquette tombait alors sur une autre colonne, ou sur aucune, sans
que rien ne le dise.
"""
def test_c_est_l_index_quelle_que_soit_l_etiquette(self):
for etiquette in ("montant", "", " ", None, "4"):
with self.subTest(etiquette=etiquette):
self.assertEqual(
transform_form.cle_de_colonne(
{"index": 4, "etiquette": etiquette}
),
4,
)
def test_deux_colonnes_ne_partagent_jamais_une_cle(self):
"""L'ambiguïté qui oblige l'invite à préférer l'étiquette — « 1 »
désigne la colonne étiquetée « 1 » ET la première — n'existe pas
ici."""
cles = {
transform_form.cle_de_colonne(c)
for c in (
{"index": 1, "etiquette": "3"},
{"index": 3, "etiquette": "x"},
)
}
self.assertEqual(len(cles), 2)
class TestBasculer(unittest.TestCase):
"""La décision, séparée du rendu pour être éprouvable."""
def test_ajoute_puis_retire(self):
ensemble = set()
self.assertTrue(transform_form.basculer(ensemble, "a"))
self.assertEqual(ensemble, {"a"})
self.assertFalse(transform_form.basculer(ensemble, "a"))
self.assertEqual(ensemble, set())
class TestSpec(unittest.TestCase):
"""La spec traverse un `json.dumps` : ni `set`, ni clé tuple."""
def setUp(self):
self.ctx = transform_form.contexte_depuis_rapport(rapport())
def test_la_spec_est_serialisable(self):
import json
spec = transform_form.spec_depuis_etat(
self.ctx, {"Ventes": {2}}, {"Ventes": {1, 2}}
)
json.dumps(spec, allow_nan=False)
self.assertEqual(
spec["colonnes_intactes_index_par_feuille"], {"Ventes": [2]}
)
self.assertEqual(spec["entetes_par_feuille"]["Ventes"], [1, 2])
def test_une_feuille_sans_colonne_cochee_ne_figure_pas(self):
spec = transform_form.spec_depuis_etat(
self.ctx, {"Ventes": set(), "Achats": set()}, {}
)
self.assertEqual(spec["colonnes_intactes_index_par_feuille"], {})
def test_CHAQUE_feuille_porte_son_empan_meme_vide(self):
"""Un empan vide veut dire « pas d'en-tête », ce qui met la ligne
1 en portée : le taire vaudrait « la ligne 1 », l'inverse."""
spec = transform_form.spec_depuis_etat(self.ctx, {}, {"Ventes": {1}})
self.assertEqual(
sorted(spec["entetes_par_feuille"]), ["Achats", "Ventes"]
)
self.assertEqual(spec["entetes_par_feuille"]["Achats"], [])
@unittest.skipUnless(TEXTUAL, SANS_TEXTUAL)
class TestStructureDeLApp(unittest.TestCase):
"""Ce que l'app promet, sans monter un widget."""
def setUp(self):
self.app = transform_form.run_transform_form(
transform_form.contexte_depuis_rapport(rapport()), run_app=False
)
def _touches(self):
rendu = {}
for item in self.app.BINDINGS:
if isinstance(item, tuple):
rendu[item[0]] = item[1]
else:
rendu[item.key] = item.action
return rendu
def test_chaque_touche_a_son_action(self):
for touche, action in self._touches().items():
with self.subTest(touche=touche):
self.assertTrue(
hasattr(self.app, "action_%s" % action),
"action_%s manque" % action,
)
def test_les_touches_promises_sont_la(self):
touches = self._touches()
for touche in ("space", "f5", "f9", "escape"):
with self.subTest(touche=touche):
self.assertIn(touche, touches)
def test_l_empan_mesure_est_le_point_de_depart(self):
"""Une correction part de ce que le moteur a trouvé ; les cases
de colonne partent VIDES, une réponse ne se pré-cochant pas."""
self.assertEqual(self.app._entetes, {"Ventes": {1}, "Achats": set()})
self.assertEqual(
self.app._intactes, {"Ventes": set(), "Achats": set()}
)
@unittest.skipUnless(TEXTUAL, SANS_TEXTUAL)
class TestPilotage(unittest.TestCase):
"""L'écran répond-il aux touches ? Seul un pilotage le prouve.
Tout le reste passerait sur un écran qui ne s'affiche pas.
"""
def _piloter(self, scenario, taille=None):
app = transform_form.run_transform_form(
transform_form.contexte_depuis_rapport(rapport()), run_app=False
)
async def tourner():
async with app.run_test(size=taille) as pilote:
await pilote.pause()
await scenario(app, pilote)
asyncio.run(tourner())
return app
def _rendu(self, app):
"""Ce que l'écran AFFICHE, non ce que le modèle porte.
Tous les autres pilotages lisent `app._intactes` ou une cellule
du modèle. C'est ce qui a laissé passer un défaut d'AFFICHAGE
complet : Rich lit `[x]` comme une balise de style et l'avale,
donc la case cochée n'apparaissait jamais — ni dans le tableau,
ni dans la légende — alors que le modèle disait « cochée ».
"""
return "\n".join(
"".join(segment.text for segment in bande)
for bande in app.screen._compositor.render_strips()
)
def _ligne_rendue(self, app, repere):
"""La ligne AFFICHÉE qui porte ce repère.
Chercher la marque dans tout l'écran ne prouverait rien : la
légende en porte une, et la ligne d'en-tête mesurée est
pré-cochée dans l'autre tableau.
"""
for ligne in self._rendu(app).splitlines():
if repere in ligne:
return ligne
return ""
def test_la_case_cochee_APPARAIT_a_l_ecran(self):
vu = {}
async def scenario(app, pilote):
table = app.query_one("#colonnes")
table.focus()
await pilote.pause()
vu["avant"] = self._ligne_rendue(app, "montant")
await pilote.press("space")
await pilote.pause()
vu["apres"] = self._ligne_rendue(app, "montant")
self._piloter(scenario, taille=(100, 30))
self.assertIn(transform_form.MARQUE_EN_PORTEE, vu["avant"])
self.assertIn(transform_form.MARQUE_INTACTE, vu["apres"])
self.assertNotIn(transform_form.MARQUE_EN_PORTEE, vu["apres"])
def test_la_legende_montre_CHAQUE_marque_a_l_ecran(self):
"""La légende traversait le même filtre : son `[x]` disparaissait,
donc l'opérateur ne pouvait pas non plus apprendre la marque."""
vu = {}
async def scenario(app, pilote):
vu["texte"] = self._rendu(app)
self._piloter(scenario, taille=(100, 30))
for nom in ("MARQUE_INTACTE", "MARQUE_PLANCHER", "MARQUE_CORRIGEE"):
with self.subTest(marque=nom):
self.assertIn(getattr(transform_form, nom), vu["texte"])
def test_une_valeur_a_crochets_n_est_pas_mangee(self):
"""La colonne des exemples porte des valeurs du FICHIER : l'une
qui ressemble à une balise ne doit pas disparaître."""
vu = {}
rap = rapport()
rap["feuilles"][0]["colonnes"][0]["exemples"] = ["[x]", "[bold]", "a"]
app = transform_form.run_transform_form(
transform_form.contexte_depuis_rapport(rap), run_app=False
)
async def tourner():
async with app.run_test(size=(120, 30)) as pilote:
await pilote.pause()
vu["texte"] = self._rendu(app)
asyncio.run(tourner())
self.assertIn("[bold]", vu["texte"])
def test_une_ligne_a_UNE_cellule_par_EN_TETE_monte(self):
"""Une cellule manquante ne lève pas : `DataTable` complète la
ligne, et les valeurs glissent d'une colonne — les exemples
s'affichaient sous « distinctes » sans que rien ne le dise.
Le nombre est pris sur les en-têtes RÉELLEMENT montés, non sur un
littéral : un « 7 » en dur dériverait au prochain ajout.
"""
vu = {}
async def scenario(app, pilote):
for selecteur in ("#colonnes", "#sondees"):
vu[selecteur] = len(app.query_one(selecteur).columns)
self._piloter(scenario)
contexte = transform_form.contexte_depuis_rapport(rapport())
feuille = contexte["feuilles"][0]
for colonne in feuille["colonnes"]:
with self.subTest(colonne=colonne["index"]):
self.assertEqual(
len(
transform_form.libelle_de_colonne(
colonne, transform_form.MARQUE_EN_PORTEE
)
),
vu["#colonnes"],
)
for ligne in feuille["lignes_sondees"]:
with self.subTest(ligne=ligne["numero"]):
self.assertEqual(
len(transform_form.libelle_de_ligne_sondee(ligne, False)),
vu["#sondees"],
)
def test_les_deux_tableaux_tiennent_sur_vingt_lignes(self):
"""Une hauteur fixe donnait ZÉRO ligne au tableau des colonnes.
C'est là que se décide « laisser cette colonne intacte » : le
tableau était présent, focalisable, et invisible. Les deux se
partagent donc la hauteur au lieu d'en réserver une.
"""
vu = {}
async def scenario(app, pilote):
for selecteur in ("#colonnes", "#sondees"):
vu[selecteur] = app.query_one(selecteur).size.height
self._piloter(scenario, taille=(80, 20))
self.assertGreaterEqual(vu["#colonnes"], 4)
self.assertGreaterEqual(vu["#sondees"], 4)
def test_la_colonne_des_mesures_n_est_pas_poussee_hors_de_l_ecran(self):
"""Trois exemples de quarante caractères font cent vingt-six
colonnes : la colonne libre ne va qu'en dernière place, sans quoi
elle chasse les mesures, qui sont la raison de montrer ces
lignes."""
vu = {}
async def scenario(app, pilote):
table = app.query_one("#sondees")
vu["largeurs"] = [c.width for c in table.columns.values()]
vu["volet"] = table.size.width
self._piloter(scenario, taille=(80, 20))
*avant_la_derniere, _derniere = vu["largeurs"]
# Toutes bornées sauf la dernière, et leur somme tient dans le
# volet : la colonne libre COMMENCE donc à l'écran.
self.assertTrue(all(l for l in avant_la_derniere))
self.assertLess(sum(avant_la_derniere), vu["volet"])
def test_les_deux_tableaux_se_remplissent(self):
vu = {}
async def scenario(app, pilote):
vu["colonnes"] = app.query_one("#colonnes").row_count
vu["sondees"] = app.query_one("#sondees").row_count
self._piloter(scenario)
self.assertEqual(vu["colonnes"], 3)
self.assertEqual(vu["sondees"], 2)
def test_espace_sur_une_colonne_la_laisse_intacte(self):
async def scenario(app, pilote):
app.query_one("#colonnes").focus()
await pilote.pause()
await pilote.press("space")
await pilote.pause()
app = self._piloter(scenario)
# L'INDEX, non l'étiquette : c'est la seule désignation qui
# survit à une correction d'en-tête.
self.assertEqual(app._intactes["Ventes"], {1})
def test_espace_sur_une_colonne_PLANCHEIEE_ne_fait_rien(self):
"""Le plancher passe avant la réponse : une case qui ne
changerait rien serait un mensonge."""
async def scenario(app, pilote):
table = app.query_one("#colonnes")
table.focus()
await pilote.pause()
table.move_cursor(row=2)
await pilote.pause()
await pilote.press("space")
await pilote.pause()
app = self._piloter(scenario)
self.assertEqual(app._intactes["Ventes"], set())
def test_espace_sur_une_ligne_ajoute_une_ligne_d_en_tete(self):
"""Plusieurs lignes d'en-tête : un export porte souvent une ligne
de catégorie au-dessus de la ligne de champs."""
async def scenario(app, pilote):
table = app.query_one("#sondees")
table.focus()
await pilote.pause()
table.move_cursor(row=1)
await pilote.pause()
await pilote.press("space")
await pilote.pause()
app = self._piloter(scenario)
self.assertEqual(app._entetes["Ventes"], {1, 2})
self.assertIn("Ventes", app._corrigees)
def test_f4_rend_la_feuille_a_la_mesure(self):
async def scenario(app, pilote):
app.query_one("#sondees").focus()
await pilote.pause()
await pilote.press("space")
await pilote.pause()
await pilote.press("f4")
await pilote.pause()
app = self._piloter(scenario)
self.assertEqual(app._entetes["Ventes"], {1})
self.assertNotIn("Ventes", app._corrigees)
def test_f5_rend_la_spec(self):
async def scenario(app, pilote):
await pilote.press("f5")
await pilote.pause()
app = self._piloter(scenario)
spec = app._resultat["spec"]
self.assertIsInstance(spec, dict)
self.assertIn("entetes_par_feuille", spec)
def test_f9_rend_un_dict_VIDE_et_non_None(self):
"""L'appelant distingue « poser les questions » de « annuler » :
les confondre supprimerait le repli."""
async def scenario(app, pilote):
await pilote.press("f9")
await pilote.pause()
app = self._piloter(scenario)
self.assertEqual(app._resultat["spec"], {})
def test_echap_annule(self):
async def scenario(app, pilote):
await pilote.press("escape")
await pilote.pause()
app = self._piloter(scenario)
self.assertIsNone(app._resultat["spec"])
if __name__ == "__main__":
unittest.main()