From a49d8178a2d1b109e1aaa36b6b3b107aadafbbf4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 10 Sep 2026 00:46:07 -0400 Subject: [PATCH 1/2] =?UTF-8?q?[FIX]=20test=20vpn=20:=20un=20nom=20d'organ?= =?UTF-8?q?isation=20tierce,=20remplac=C3=A9=20par=20un=20invent=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La règle des conventions est explicite : l'exemple qui illustre un interdit s'invente, et un test fige sa valeur pour toujours. Le nom d'une organisation tierce servait de profil et de passerelle dans quatorze occurrences, choisi parce qu'il était parlant — exactement le réflexe que la règle combat. Le remplaçant est inventé et vérifié absent du reste du dépôt. Ce que le test éprouve ne change pas : un profil sans route reste accepté et signalé. --- EN --- The conventions are explicit: an example illustrating a prohibition is invented, and a test freezes its value forever. A third-party organisation's name served as profile and gateway in fourteen places, picked because it spoke — exactly the reflex the rule fights. The replacement is invented and verified absent from the rest of the repository. What the test proves does not change: a profile without routes is still accepted and flagged. Assisted-by: Claude Opus 5 --- test/test_vpn_menu.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/test_vpn_menu.py b/test/test_vpn_menu.py index 1a376e8..61dbc3c 100644 --- a/test/test_vpn_menu.py +++ b/test/test_vpn_menu.py @@ -372,9 +372,9 @@ class OnlyWhatTheSiteGaveYou(MenuBase): def test_a_profile_without_routes_is_accepted_and_flagged(self): names = list(DRIVERS) answers = [ - "novipro", + "cortalis", DRIVER_LETTERS[names.index("l2tp_ipsec")], - "vpn.novipro.example", # la passerelle + "vpn.cortalis.example", # la passerelle "user", # l'utilisateur PPP "", # réseaux : le site n'en a pas donné "", # tout le trafic ? non @@ -385,7 +385,7 @@ class OnlyWhatTheSiteGaveYou(MenuBase): with self.answering(*answers): with redirect_stdout(buffer): self.todo._vpn_edit_profile() - saved = profiles.load("novipro") + saved = profiles.load("cortalis") self.assertIsNotNone(saved, "profil refusé alors qu'il est utilisable") self.assertEqual(saved["routes"], []) self.assertFalse(saved["default_route"]) @@ -401,7 +401,7 @@ class OnlyWhatTheSiteGaveYou(MenuBase): profile = profiles.validate( { - "name": "novipro", + "name": "cortalis", "driver": "l2tp_ipsec", "server": "127.0.0.1", "ppp_user": "user", @@ -427,7 +427,7 @@ class OnlyWhatTheSiteGaveYou(MenuBase): profile = profiles.validate( { - "name": "novipro", + "name": "cortalis", "driver": "l2tp_ipsec", "server": "127.0.0.1", "ppp_user": "user", @@ -489,9 +489,9 @@ class SecretsOnlyWhenThereAreSome(MenuBase): """ profiles.save( { - "name": "novipro", + "name": "cortalis", "driver": "l2tp_ipsec", - "server": "vpn.novipro.example", + "server": "vpn.cortalis.example", "ppp_user": "user", } ) @@ -505,7 +505,7 @@ class SecretsOnlyWhenThereAreSome(MenuBase): return "secret" with patch.object( - self.todo, "_vpn_select_profile", return_value="novipro" + self.todo, "_vpn_select_profile", return_value="cortalis" ): with self.answering(coffre, "o"): with patch("getpass.getpass", masked): @@ -522,9 +522,9 @@ class SecretsOnlyWhenThereAreSome(MenuBase): """ profiles.save( { - "name": "novipro", + "name": "cortalis", "driver": "l2tp_ipsec", - "server": "vpn.novipro.example", + "server": "vpn.cortalis.example", "ppp_user": "user", } ) @@ -540,7 +540,7 @@ class SecretsOnlyWhenThereAreSome(MenuBase): buffer = io.StringIO() with patch.object( - self.todo, "_vpn_select_profile", return_value="novipro" + self.todo, "_vpn_select_profile", return_value="cortalis" ): with self.answering(coffre, "o"): with patch("getpass.getpass", masked): @@ -558,9 +558,9 @@ class SecretsOnlyWhenThereAreSome(MenuBase): def test_a_field_already_set_says_so(self): profiles.save( { - "name": "novipro", + "name": "cortalis", "driver": "l2tp_ipsec", - "server": "vpn.novipro.example", + "server": "vpn.cortalis.example", "ppp_user": "user", } ) @@ -574,7 +574,7 @@ class SecretsOnlyWhenThereAreSome(MenuBase): return "valeur" with patch.object( - self.todo, "_vpn_select_profile", return_value="novipro" + self.todo, "_vpn_select_profile", return_value="cortalis" ): with self.answering(coffre, "o"): with patch("getpass.getpass", masked): From 04b9717e96c979fb62726b0d3038e78cf6873f19 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 10 Sep 2026 23:58:01 -0400 Subject: [PATCH 2/2] [ADD] transform data : anonymiser un fichier externe ou une base Odoo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.base.md | 17 +- CHANGELOG.fr.md | 9 +- CHANGELOG.md | 8 + requirement/todo_external_data.txt | 28 + script/analyse/anonymize.py | 338 +- script/data/external_file.py | 1923 +++++++ script/data/external_file_formats.py | 3654 +++++++++++++ script/todo/todo.py | 107 +- script/todo/todo_i18n.py | 770 +++ script/todo/transform_form.py | 555 ++ script/todo/transform_menu.py | 1413 +++++ script/todo/transform_setup.py | 236 + test/test_anonymize.py | 923 ++++ test/test_todo.py | 293 ++ test/test_todo_menu.py | 1 + test/test_transform_anonymise.py | 407 ++ test/test_transform_external.py | 7206 ++++++++++++++++++++++++++ test/test_transform_form.py | 708 +++ 18 files changed, 18543 insertions(+), 53 deletions(-) create mode 100644 requirement/todo_external_data.txt create mode 100644 script/data/external_file.py create mode 100644 script/data/external_file_formats.py create mode 100644 script/todo/transform_form.py create mode 100644 script/todo/transform_menu.py create mode 100644 script/todo/transform_setup.py create mode 100644 test/test_transform_anonymise.py create mode 100644 test/test_transform_external.py create mode 100644 test/test_transform_form.py diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 20b409c..3ce1a3b 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -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 - `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 +- 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 @@ -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 - `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 - +- 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 ## Changed @@ -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 - 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 +- `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 @@ -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 - 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 +- `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 ## Removed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 20f62e2..7cf5ad5 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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 - `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 - +- 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é - `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 - 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 +- `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é diff --git a/CHANGELOG.md b/CHANGELOG.md index c825f29..9955a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - `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 +- 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 @@ -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 - 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 +- `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 diff --git a/requirement/todo_external_data.txt b/requirement/todo_external_data.txt new file mode 100644 index 0000000..d173b94 --- /dev/null +++ b/requirement/todo_external_data.txt @@ -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 diff --git a/script/analyse/anonymize.py b/script/analyse/anonymize.py index e9ab434..0019a02 100755 --- a/script/analyse/anonymize.py +++ b/script/analyse/anonymize.py @@ -64,6 +64,54 @@ from script.analyse import lib_analyse # noqa: E402 TYPES_TEXTE = ("char", "text", "html") 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. PREFIXES_INTERDITS = ("ir.",) MODELES_INTERDITS = frozenset( @@ -169,6 +217,16 @@ MOTS_PAR_DEFAUT = ( 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): """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. """ + 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: - return False + return REFUS_PLANCHER 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"): # Une relation qui aurait échappé au filtre de ttype. - return False + return REFUS_RELATION if champ["name"] in CHAMPS_STRUCTURES: - return False + return REFUS_STRUCTURE if champ.get("checked"): # Une contrainte CHECK hors de portée. Mesuré, et la distinction # 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 # pas les noms. La requête ne lève donc ce drapeau, pour du texte, # que sur les contraintes de FORME. - return False + return REFUS_CONTRAINTE if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb": # Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut # vivre dans un jsonb par société. Y écrire un nombre nu ferait # échouer l'UPDATE — et donc, transaction unique oblige, TOUTE # 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 champ["ttype"] in TYPES_TEXTE + TYPES_NOMBRE + return champ["ttype"] in TYPES_NOMBRE and bool(champ.get("unique")) 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" +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): """Le SQL qui remplace un nombre, DANS l'étendue de la colonne. @@ -331,9 +517,13 @@ def expression_nombre(champ): nom = ident(champ["name"]) bas, haut = champ.get("borne_min"), champ.get("borne_max") 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: tirage = ( - "floor(random() * 1001)::integer" + f"floor(random() * 1001)::{coulee}" if entier 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, # le tirage rend cette valeur, ce qui est sans risque : une # 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: tirage = f"round(({bas} + random() * ({haut} - {bas}))::numeric, 2)" return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END" -def sql_pour_table(table, champs, mots): - """Un seul UPDATE par table : toutes ses colonnes d'un coup.""" +def sql_pour_table(table, champs, mots, calibre=False): + """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 = [] for champ in champs: if champ["ttype"] in TYPES_TEXTE: valeur = expression_texte(champ, mots) + elif calibre: + valeur = expression_calibre(champ) else: valeur = expression_nombre(champ) morceaux.append(f"{ident(champ['name'])} = {valeur}") @@ -448,6 +647,7 @@ def plan( blacklist=(), inclure_connexion=False, mots=None, + calibre=False, ): """Ce qui sera écrit, table par table — avant d'écrire quoi que ce soit. @@ -459,20 +659,51 @@ def plan( ) retenus = set(modeles) 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: if champ["model"] not in retenus: 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 par_modele.setdefault(champ["model"], []).append(champ) etapes = [] for modele in modeles: + laisses = sorted(abstenus.get(modele, ())) liste = par_modele.get(modele) - if not liste: - continue - sql = sql_pour_table(table_de(modele), liste, mots) + sql = ( + sql_pour_table(table_de(modele), liste, mots, calibre) + if liste + else None + ) 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 @@ -564,8 +795,13 @@ def nombre_valide(texte): return True -def appliquer_sondes(etapes, ecartees, bornes, mots): - """Refaire le plan sans les écartées, et avec les bornes mesurées.""" +def appliquer_sondes(etapes, ecartees, bornes, mots, calibre=False): + """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 = [] for etape in etapes: 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 # le `if sql` ci-dessous l'écarte. Deux vérifications pour la même # 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: 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 @@ -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.""" if not etapes: 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 = ( - 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, ""] 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] nombres = [f for f in etape["fields"] if f["ttype"] in TYPES_NOMBRE] traduits = [f for f in textes if f["pg_type"] == "jsonb"] @@ -610,13 +866,23 @@ def render(etapes, applique=False, verbeux=False): if uniques: detail += f", {len(uniques)} {t('unique')}" 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: for champ in etape["fields"]: lignes.append( f" {champ['name']:<30} {champ['ttype']}" f" / {champ['pg_type']}" ) - if not applique: + if travail and not applique: lignes.append("") lignes.append(f" {t('Use --apply --confirm to write.')}") 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["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 à # 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("--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( "--confirm", @@ -766,11 +1038,12 @@ def main(argv=None): [m.strip() for m in args.exclude.split(",") if m.strip()], args.include_logins, mots, + args.keep_digits, ) # La sonde AVANT le rendu : la marche à blanc doit montrer ce que # `--apply` ferait, pas une approximation plus large. 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: combien = sum(len(v) for v in ecartees.values()) print( @@ -781,16 +1054,27 @@ def main(argv=None): print(f" {modele} : {', '.join(sorted(ecartees[modele]))}") 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: 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) if erreur: print(f"❌ {t('Nothing was written:')} {erreur}", file=sys.stderr) - return 2 + return SORTIE_REFUS print(render(etapes, applique=True, verbeux=args.verbose)) - return 0 + return SORTIE_RIEN if __name__ == "__main__": diff --git a/script/data/external_file.py b/script/data/external_file.py new file mode 100644 index 0000000..c1412cc --- /dev/null +++ b/script/data/external_file.py @@ -0,0 +1,1923 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Décrire un fichier externe, puis en tirer une copie anonymisée. + +Excel, Access, CSV, XML et JSON. Aucune IA, aucun appel réseau : des mots +pris dans une liste locale et des nombres tirés dans l'étendue mesurée de +leur colonne. + +Ce qui rend la chose délicate n'est pas de remplacer une cellule, c'est de +savoir CE QUI PORTE DE LA DONNÉE. Un classeur en porte dans une douzaine +d'endroits qui ne sont pas des cellules — d'où `nettoyer_hors_cellules`, et +d'où le fait que sa liste sorte d'une MESURE et non d'une lecture du schéma. + +Deux interpréteurs, un seul module +---------------------------------- +Les formats en pur stdlib — CSV, JSON, XML, et la détection de macros qui +n'est qu'un `zipfile.namelist()` — tournent sous l'interpréteur du CLI. +Excel et Access exigent un venv dédié. Le module doit donc s'importer sous +les deux : AUCUN import de bibliothèque tierce au niveau du module, chacun +vit dans la fonction qui en a besoin. Les tests unitaires du dépôt tournent +sous `.venv.erplibre`, qui n'a pas openpyxl : un import au niveau du module +les ferait tomber tous, y compris ceux des règles pures. + +Le canal de sortie +------------------ +stdout ne porte QUE du JSON, un seul objet. Tout le reste — progression, +avertissements de bibliothèque — va sur stderr. Un appelant qui lit stdout +n'a donc rien à filtrer. +""" + +from __future__ import annotations + +import argparse +import datetime +import decimal +import html +import json +import math +import os +import re +import sys +import tempfile +import unicodedata +import zipfile + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse.anonymize import MOTS_PAR_DEFAUT # noqa: E402 + +# Les clés d'erreur, à UN seul endroit. Le moteur n'écrit jamais un libellé +# ailleurs : `t()` rend la clé quand elle manque, sans lever, donc un libellé +# dispersé se traduirait en silence par de l'anglais. Le test de §10 balaie +# CETTE constante contre TRANSLATIONS. +ERREURS = { + "format_inconnu": "Format not recognised: ", + "illisible_ici": "Recognised format, unreadable here" + " — re-save it as .xlsx.", + "protege": "Protected by a password, or not a workbook" + " — unreadable here.", + "vide": "Empty file.", + "droits": "Not readable: check the permissions.", + "pas_un_fichier": "Not an ordinary file.", + "destination_source": "The destination is the source file;" + " nothing was written.", + "repertoire_non_vide": "The destination directory exists" + " and is not empty.", + "aucune_feuille": "The selection matches no sheet;" + " nothing was written.", + "tout_exclu": "Nothing was anonymised: every region was excluded.", + "rien_a_faire": "Nothing to anonymise in this file.", + "place": "Not enough room to write.", + "conversion_impossible": "This target cannot hold the source's" " shape: ", + "table_source": "The mapping table would overwrite the source" + " or the copy; nothing was written.", + "table_illisible": "The mapping table is unreadable," + " or is not a mapping table: ", + "fuite_detectee": "A source value survives in the copy;" + " nothing was written: ", + "lecture_impossible": "The library here cannot read this file: ", +} + +# Les sept constantes d'erreur d'Excel. Elles arrivent en `str` SANS `=` en +# tête : la règle de la formule ne les retient pas, et celle du texte les +# changerait en mot — ce qui fait répondre FAUX à un SIERREUR resté intact +# à côté. La liste est RECOPIÉE et non importée d'openpyxl : les règles +# pures doivent se tester sans lui. +VALEURS_ERREUR = frozenset( + { + "#NULL!", + "#DIV/0!", + "#VALUE!", + "#REF!", + "#NAME?", + "#NUM!", + "#N/A", + } +) + +# xlrd stocke une cellule d'erreur par son CODE BIFF, en entier. Sans cette +# traduction, la règle du nombre en fait un montant plausible, et celle de +# l'erreur — qui teste les sept chaînes — ne se déclenche jamais. Le code 0 +# est le piège : « 0 reste 0 » le laisserait passer pour un zéro légitime. +CODES_ERREUR_XLS = { + 0: "#NULL!", + 7: "#DIV/0!", + 15: "#VALUE!", + 23: "#REF!", + 29: "#NAME?", + 36: "#NUM!", + 42: "#N/A", +} + +# Une étiquette de colonne qui porte un identifiant. `anonymize.py` refuse +# par le NOM avant de regarder la moindre valeur, et sa docstring dit ce que +# ça lui a coûté : des champs `selection` où « écrire un mot au hasard casse +# l'ORM, pas la confidentialité », et des relations entières dont la +# randomisation mélangerait toute la base. Un export tableur de cette même +# base porte les mêmes colonnes sous les mêmes étiquettes. +# Une étiquette dont le CONTENU n'est jamais du texte libre : le plancher +# peut tomber sur le nom seul sans rien laisser passer. +PLANCHER_STRUCTUREL = frozenset( + { + "id", + "create_uid", + "write_uid", + "create_date", + "write_date", + "sequence", + "active", + "color", + "res_field", + "__last_update", + "arch_fs", + } +) +SUFFIXES_STRUCTURELS = ("/id", "/.id") + +# Une étiquette qui PEUT porter du texte libre. Un export Odoo +# import-compatible met le nom affiché de la relation dans « partner_id », et +# « State », « Key » ou « Model » d'un classeur ordinaire ne sont pas les +# champs d'Odoo. Le plancher n'y tombe que si le contenu MESURÉ a la forme +# d'un identifiant : décider sur le nom seul recopiait textuellement les +# colonnes les plus identifiantes du fichier. +SUFFIXES_IDENTIFIANTS = ("_id", "_ids") +# Les étiquettes dont le contenu légitime EST une clé technique en +# minuscules. `display_name` n'en fait pas partie : dans un fichier plat il +# EST la donnée, et aucune forme mesurée ne doit le sauver. +# Ces étiquettes portent légitimement un jeton POINTÉ — un nom de modèle, +# un external ID. Elles exigent la même preuve qu'une relation. +ETIQUETTES_POINTEES = frozenset({"key", "model", "res_model", "arch_db"}) + +# `state` porte une valeur de SÉLECTION : un jeton minuscule pris dans un +# ensemble fermé et petit. Le seul test de forme accepterait n'importe quel +# mot minuscule — une colonne de provinces, ou de créneaux nommés par des +# personnes — d'où la borne sur le nombre de valeurs distinctes. +ETIQUETTES_SELECTION = frozenset({"state"}) + +# Au-delà, ce n'est plus une sélection : c'est une colonne de texte dont +# les valeurs se trouvent être en minuscules. +SELECTION_MAX_DISTINCTES = 12 + +# Un chemin d'identifiants Odoo, une valeur de sélection, un external ID. +_MOTIF_CHEMIN_ID = re.compile(r"[0-9]+(?:/[0-9]+)*/?") +_MOTIF_SELECTION = re.compile(r"[a-z0-9_]+") +# Un external ID, un nom de modèle : minuscules et AU MOINS un point. Ne pas +# borner le nombre de points — « account.move.line » en porte deux. +_MOTIF_POINTE = re.compile(r"[a-z0-9_]+(?:\.[a-z0-9_]+)+") +# Un nom de fichier a la forme d'un external ID : des mots, des chiffres, +# des points. C'est l'EXTENSION qui le trahit, et un fichier de paie porte +# le nom de la personne. +EXTENSIONS_FICHIER = frozenset( + { + "pdf", + "doc", + "docx", + "odt", + "xls", + "xlsx", + "xlsm", + "csv", + "ods", + "ppt", + "pptx", + "odp", + "txt", + "rtf", + "zip", + "png", + "jpg", + "jpeg", + "gif", + "svg", + "eml", + "msg", + "xml", + "json", + "html", + "htm", + } +) + + +def _pointe_identifiant(texte): + """Vrai pour « account.move.line », faux pour « rapport.pdf ».""" + if not _MOTIF_POINTE.fullmatch(texte): + return False + return texte.rsplit(".", 1)[1] not in EXTENSIONS_FICHIER + + +def valeur_forme_identifiant(valeur): + """Vrai si cette valeur a la forme d'un identifiant, non d'un nom. + + Le doute profite à l'ANONYMISATION : ce qui n'est pas franchement un + identifiant est traité comme du texte, donc remplacé. L'inverse — croire + identifiant ce qui est un nom — recopie la donnée en clair et l'annonce + comme protégée. + """ + if isinstance(valeur, bool) or isinstance(valeur, int): + return True + if isinstance(valeur, float): + return valeur.is_integer() + if not isinstance(valeur, str): + return False + texte = valeur.strip() + if not texte: + return True + if _MOTIF_CHEMIN_ID.fullmatch(texte): + return True + if _MOTIF_SELECTION.fullmatch(texte): + return True + # Un external ID porte un point ; la virgule tient la liste d'un m2m. + # Exiger les MINUSCULES et refuser une extension de fichier : sinon + # « Paie_Marie_2025_03.pdf » et « clinique.exemple.com » passaient pour + # des identifiants, et le plancher les recopiait en clair. + return all(_pointe_identifiant(p) for p in texte.split(",")) + + +# Un external ID PROUVÉ : un local numéroté, ou le module sentinelle +# qu'Odoo écrit lui-même à l'export. +_MOTIF_XMLID_NUMEROTE = re.compile(r"[a-z0-9_]+\.[a-z0-9_]*_[0-9]+") +_MODULES_EXPORT = ("__export__.", "__import__.") + + +def _cible_externe(texte): + """Vrai pour un external ID PROUVÉ, faux pour un login pointé. + + « base.res_partner_7 » et « jean.tremblay » ont exactement la même + forme, et compter les préfixes communs d'une colonne ne tranchait pas : + une équipe entière de logins partage son domaine, et une colonne à une + seule valeur n'a aucun préfixe à comparer. Ce qui PROUVE un external + ID est le numéro de son local, ou le module sentinelle de l'export. + + Sans preuve, la valeur est du texte et part au remplacement : le doute + profite à l'anonymisation. Une colonne de noms de modèles — `res_model` + portant « account.move » — est donc anonymisée elle aussi, faute de + pouvoir la distinguer d'une colonne de personnes. + """ + brut = texte.strip() + if not _pointe_identifiant(brut): + return False + if brut.startswith(_MODULES_EXPORT): + return True + return bool(_MOTIF_XMLID_NUMEROTE.fullmatch(brut)) + + +def valeur_forme_relation(valeur): + """Vrai si cette valeur peut être la CIBLE d'une relation. + + Plus étroit que `valeur_forme_identifiant` : un mot en minuscules n'est + pas une valeur de relation. C'est ce qui laissait « user_id » porter un + login et « department_id » un nom de service, tous deux recopiés en + clair et annoncés comme protégés. + """ + if isinstance(valeur, bool) or isinstance(valeur, int): + return True + if isinstance(valeur, float): + return valeur.is_integer() + if not isinstance(valeur, str): + return False + texte = valeur.strip() + if not texte: + return True + if _MOTIF_CHEMIN_ID.fullmatch(texte): + return True + return all(_cible_externe(p) for p in texte.split(",")) + + +# Les octets de tête qui tranchent, quand l'extension mentirait. +SIGNATURES = ( + (b"PK\x03\x04", "opc"), + (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "ole2"), +) + +EXTENSIONS = { + ".xlsx": "xlsx", + ".xlsm": "xlsx", + ".xlsb": "xlsb", + ".xls": "xls", + ".mdb": "access", + ".accdb": "access", + ".csv": "csv", + ".xml": "xml", + ".json": "json", +} + +FORMATS_STDLIB = frozenset({"csv", "xml", "json"}) +FORMATS_LECTURE_SEULE = frozenset({"xls", "xlsb", "access"}) + + +def progres(message): + """Une ligne de progression, sur stderr. stdout est réservé au JSON.""" + print(f"# {message}", file=sys.stderr, flush=True) + + +# ---------------------------------------------------------------------- +# La porte d'entrée +# ---------------------------------------------------------------------- +def verifier_source(chemin): + """None si le fichier est exploitable, sinon une clé d'`ERREURS`. + + Appelée AVANT `detect_format` : un répertoire, un lien cassé ou un + fichier sans droit de lecture ne doivent pas arriver jusqu'à une + bibliothèque, qui les rapporterait par une exception en anglais. + """ + if not os.path.isfile(chemin): + return "pas_un_fichier" + if not os.access(chemin, os.R_OK): + return "droits" + try: + if os.path.getsize(chemin) == 0: + return "vide" + except OSError: + return "droits" + return None + + +def signature(chemin): + """La famille de conteneur, lue dans les premiers octets.""" + try: + with open(chemin, "rb") as fh: + tete = fh.read(8) + except OSError: + return "" + for octets, nom in SIGNATURES: + if tete.startswith(octets): + return nom + if tete[:1] == b"<": + return "balise" + return "texte" + + +def detect_format(chemin): + """Le format, décidé par le CONTENU ; l'extension n'est qu'un indice. + + Le cas fréquent est le fichier qui mente sur son extension : un export + d'ERP nommé `.xls` qui est du HTML, un `.xlsx` qui est un `.xls`, un + classeur protégé par mot de passe — lequel est un conteneur OLE2, donc + indiscernable d'un `.xls` par l'extension seule et indiscernable d'un + fichier corrompu si l'on se contente de l'exception d'openpyxl. + """ + extension = os.path.splitext(chemin)[1].lower() + attendu = EXTENSIONS.get(extension, "") + sig = signature(chemin) + + if attendu == "xlsb": + return "xlsb" + if sig == "opc": + return "xlsb" if attendu == "xlsb" else "xlsx" + if sig == "ole2": + # OLE2 sous une extension OOXML : protégé ou non conforme. Jamais + # passé à openpyxl, qui ne saurait pas distinguer les deux cas. + return "xls" if attendu in ("xls", "") else "protege" + if sig == "balise": + if attendu == "json": + return "xml" + return attendu if attendu in ("xml",) else "xml" + if attendu in ("csv", "json", "access", "xml"): + return attendu + return attendu or "" + + +def format_divergent(chemin, format_lu): + """Vrai si le contenu et l'extension ne disent pas la même chose.""" + extension = os.path.splitext(chemin)[1].lower() + attendu = EXTENSIONS.get(extension, "") + return bool(attendu) and attendu != format_lu + + +def has_macros(chemin): + """Un projet VBA est-il présent ? PRÉSENCE seule, jamais un compte. + + Le zip ne porte qu'une entrée, `xl/vbaProject.bin` : la liste des + modules vit dans le compound OLE qu'elle contient, et la compter + exigerait un lecteur OLE, absent des deux venvs. + + `zipfile` LÈVE sur tout ce qui n'est pas un zip — un OLE2, un fichier + vide, un fichier tronqué — et le chemin `.xlsb` passe par cette + fonction et par elle seule. Le contrat `-> bool` l'exige donc d'être + close. + """ + try: + with zipfile.ZipFile(chemin) as z: + return "xl/vbaProject.bin" in z.namelist() + except (zipfile.BadZipFile, OSError): + return False + + +def compter_media(chemin): + """Les images du classeur, comptées par `zipfile`. + + openpyxl ne les voit que si Pillow est là, et ne les recopie jamais : + `find_images` rend une liste vide sans Pillow. Le compte doit donc venir + du conteneur, pour que le rapport puisse annoncer ce que la copie perd. + """ + try: + with zipfile.ZipFile(chemin) as z: + return sum(1 for n in z.namelist() if n.startswith("xl/media/")) + except (zipfile.BadZipFile, OSError): + return 0 + + +# ---------------------------------------------------------------------- +# Le vivier de mots +# ---------------------------------------------------------------------- +def _deplier(mot): + """Un mot sans accent ni majuscule. + + Translittérer plutôt que rejeter : « acédie » devient « acedie » au + lieu de disparaître, ce qui rend 354 mots sur 1404. Et la sortie reste + sûre partout — une valeur de cellule, un nom de fichier et un + identifiant réimporté n'ont pas la même tolérance à l'accent, et aucun + n'en a besoin. + """ + plie = unicodedata.normalize("NFKD", mot) + return "".join(c for c in plie if not unicodedata.combining(c)).lower() + + +def vivier_de_mots(): + """Les mots disponibles, triés et dédoublonnés. + + TRIÉ obligatoirement : l'ordre d'un `set` varie d'un processus à + l'autre, et l'attribution étant indexée, une table réutilisée d'une + exécution à l'autre rendrait d'autres mots pour les mêmes valeurs. + + Repli sur les 20 mots d'`anonymize` si `randomwordfr` manque — le + rapport le dit plutôt que de laisser croire au vivier complet. + """ + try: + import randomwordfr + + mots = { + _deplier(entree["word"]) + for entree in randomwordfr.data + if " " not in entree["word"] + } + mots = {m for m in mots if re.fullmatch(r"[a-z_]+", m)} + if mots: + return tuple(sorted(mots)) + except Exception: # pragma: no cover - repli si le paquet manque + pass + return tuple(sorted(MOTS_PAR_DEFAUT)) + + +def _entetes_lisibles(brut): + """Les lignes d'en-tête d'une table, réduites à ce qui s'en lit. + + Une table est un fichier du disque : elle arrive tronquée, éditée à la + main, ou d'une version que ce code ne connaît pas. Ce qui ne se lit + pas est SAUTÉ, jamais levé — la mémoire de lot est un confort, et la + perdre vaut mieux que perdre le travail. Cinq formes malformées + faisaient lever, dont une chaîne à la place d'une liste, qui s'itère + caractère par caractère. + """ + if not isinstance(brut, dict): + return {} + rendu = {} + for nom, lignes in brut.items(): + if isinstance(lignes, (str, bytes)) or not hasattr(lignes, "__iter__"): + continue + rendu[str(nom)] = lignes_entieres(lignes) + return rendu + + +def lignes_entieres(lignes): + """Des numéros de ligne 1-based, réduits à ce qui s'en lit. + + Une seule écriture de la règle : elle sert à la lecture d'une table du + disque comme à l'enregistrement d'une réponse venue d'un JSON, et deux + copies auraient fini par accepter des choses différentes. + """ + if isinstance(lignes, (str, bytes)) or not hasattr(lignes, "__iter__"): + return [] + gardees = set() + for ligne in lignes: + try: + numero = int(ligne) + except (TypeError, ValueError): + continue + if numero >= 1: + gardees.add(numero) + return sorted(gardees) + + +class Correspondance: + """La table qui donne son intégrité référentielle à la copie. + + Deux dictionnaires, parce que les deux espaces ne se mélangent pas : un + texte rend un mot, un nombre rend un nombre. L'attribution est SANS + REMISE et indexée par un compteur — jamais un tirage. Sur 20 mots tirés + au hasard, six valeurs distinctes ont déjà 56 % de chance d'en partager + un, et deux clients qui reçoivent le même mot fusionnent en une seule + clé : la RECHERCHEV résout encore, mais sur la mauvaise ligne. + + Sérialisable en JSON, pour que la question de la réutilisation puisse + porter la table d'un fichier à l'autre d'un même lot. C'est aussi le + seul objet produit qui RÉ-IDENTIFIE la copie : il vit dans `private/`. + """ + + # 2 depuis que la table se rappelle les lignes d'en-tête. Une table + # de version 1 se charge toujours : `charger` lit une clé ABSENTE + # comme un dictionnaire vide, faute de quoi le deuxième fichier d'un + # lot commencé avant refuserait la table du premier. + VERSION = 2 + + def __init__(self, mots=None, nombres=None, entetes=None): + self.mots = dict(mots or {}) + self.nombres = dict(nombres or {}) + # {nom de feuille: [lignes d'en-tête]} — ce que l'opérateur a + # RÉPONDU sur un fichier de ce lot. Le deuxième fichier d'un même + # export porte les mêmes feuilles, si bien que la correction n'est + # à faire qu'une fois. Elle n'est JAMAIS appliquée en silence : + # l'écran la montre pré-cochée, une réponse fausse appliquée sans + # être vue étant exactement comment une erreur gagne tout un lot. + self.entetes = _entetes_lisibles(entetes) + # Les nombres DÉJÀ attribués. Reconstruits au chargement, pour + # qu'une table réutilisée d'un fichier à l'autre continue de + # garantir l'unicité sur tout le lot. + self.nombres_pris = set(self.nombres.values()) + # Les mots DÉJÀ attribués, pour la même raison — et parce que le + # saut d'identité ci-dessous peut retomber sur l'un d'eux. + self.mots_pris = set(self.mots.values()) + + @classmethod + def charger(cls, chemin): + """La table du disque, ou une neuve si le chemin ne désigne rien. + + Une table ABÎMÉE, elle, refuse en la NOMMANT. L'exception nue + remontait au filet de dernier recours, qui la rendait sous + « format non reconnu » — donc en accusant le classeur source, que + l'opérateur concluait corrompu. Le cas arrive de deux façons : une + table tronquée par une interruption, et un autre fichier de + `private/` désigné à l'invite. + + L'import est TARDIF : `external_file_formats` importe cette + classe, et l'importer en tête ferait un cycle. + """ + if not chemin or not os.path.isfile(chemin): + return cls() + from script.data.external_file_formats import ErreurMoteur + + # Le conseil est la PHRASE, non une clé d'`ERREURS` : `echec` le + # rend tel quel et le menu le passe à `t()`. + conseil = "Leave the mapping table question empty to create a new one." + try: + with open(chemin, "r", encoding="utf-8") as fh: + brut = json.load(fh) + except (OSError, ValueError) as exc: + raise ErreurMoteur( + "table_illisible", + f"{chemin}: {type(exc).__name__}: {exc}", + conseil, + ) from exc + if not isinstance(brut, dict): + raise ErreurMoteur( + "table_illisible", + f"{chemin}: {type(brut).__name__}", + conseil, + ) + return cls(brut.get("mots"), brut.get("nombres"), brut.get("entetes")) + + def ecrire(self, chemin): + """Écrite en 0600, et par un temporaire renommé. + + Le mode compte parce que ce fichier porte chaque valeur d'origine en + clair : il ré-identifie les copies à lui seul. `os.open` n'applique + son mode QU'À la création, donc une table arrivée en 0644 par un + clone, un `cp` ou un `tar -x` le resterait — d'où le `fchmod`. + + L'atomicité compte parce que la table s'écrit APRÈS les copies : une + interruption laisserait sur le disque une table tronquée au milieu + d'une chaîne, alors que les fichiers qu'elle seule ré-identifie sont + déjà livrables. Le fichier suivant du lot échouerait alors à la + charger, sur un message qui accuse sa source. + """ + parent = os.path.dirname(os.path.abspath(chemin)) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + descripteur, temporaire = tempfile.mkstemp( + dir=parent, prefix=".table-", suffix=".part" + ) + try: + os.fchmod(descripteur, 0o600) + with os.fdopen(descripteur, "w", encoding="utf-8") as fh: + json.dump( + { + "version": self.VERSION, + "mots": self.mots, + "nombres": self.nombres, + "entetes": self.entetes, + }, + fh, + ensure_ascii=False, + indent=1, + sort_keys=True, + ) + os.replace(temporaire, chemin) + temporaire = None + finally: + if temporaire and os.path.exists(temporaire): + os.unlink(temporaire) + + def en_dict(self): + return { + "mots": dict(self.mots), + "nombres": dict(self.nombres), + "entetes": { + nom: list(lignes) for nom, lignes in self.entetes.items() + }, + } + + +def nouveau_mot(valeur, table, vivier): + """Le mot attribué à cette chaîne. Stable dans toute la table. + + Au-delà du vivier, DEUX MOTS APPARIÉS — 1366² fait 1 866 756 + combinaisons — et non `mot_` : la demande était un mot d'un + dictionnaire, et une sortie numérotée cesse d'en être un exactement au + moment où le fichier est assez gros pour que ça compte. `mot_` ne + subsiste qu'en troisième repli, au-delà de 1,8 million de valeurs + distinctes — jamais atteint par un tableur. + """ + cle = str(valeur) + connu = table.mots.get(cle) + if connu is not None: + return connu + taille = len(vivier) + # Le mot tiré peut ÊTRE la valeur : le vivier est un dictionnaire, et + # une cellule peut porter un de ses mots. Rendre ce mot compte un + # remplacement que la copie ne porte pas — la valeur y part en clair. + # On avance alors d'un rang plutôt que de rendre l'identité. + # `mots_pris` est indispensable : le saut d'identité seul peut retomber + # sur un mot DÉJÀ attribué à une autre valeur, et deux clients + # fusionnent alors sur un seul mot — la RECHERCHEV résout encore, mais + # sur la mauvaise ligne. Reconstruit au chargement, il vaut pour tout + # un lot. + pris = getattr(table, "mots_pris", None) + if pris is None: + pris = table.mots_pris = set(table.mots.values()) + n = len(table.mots) + while True: + if n < taille: + mot = vivier[n] + else: + rang = n - taille + gauche, droite = divmod(rang, taille) + if gauche < taille: + mot = f"{vivier[gauche]}_{vivier[droite]}" + else: + mot = f"mot_{n}" + if mot != cle and mot not in pris: + break + n += 1 + table.mots[cle] = mot + pris.add(mot) + return mot + + +def bornes_du_meme_calibre(valeur): + """L'intervalle des nombres qui ont AUTANT de chiffres que celui-ci. + + 8839 tire dans 1000..9999 : la copie garde des colonnes de la même + largeur, ce qu'un export relu à l'œil ou importé dans un champ borné + demande. Le signe suit la valeur, comme partout ailleurs. + + Rend None sous l'unité. « Garder le nombre de chiffres » n'y veut rien + dire — 0,15 n'a pas de chiffre avant la virgule — et appliquer la + règle quand même tirerait un taux entre 1 et 9, ce que l'étendue + mesurée existe précisément pour empêcher. + """ + entier = abs(int(valeur)) + if entier < 1: + return None + largeur = len(str(entier)) + bas = 10 ** (largeur - 1) + haut = 10**largeur - 1 + return (-haut, -bas) if valeur < 0 else (bas, haut) + + +def _en_flottant(valeur): + """Une borne en flottant, ramenée au plus grand si elle le dépasse. + + `json.loads` rend un entier EXACT de plusieurs centaines de chiffres, + que `float()` refuse au-delà de 1,8e308 — bien au-delà du plafond + d'un tableur, mais un `.json` n'en a pas. Lever ici faisait annoncer + le fichier comme d'un format inconnu, et la valeur repartait en clair + dans la copie. + + Ramener plutôt que lever : le tirage reste du bon signe et dans une + plage représentable, et « garder le nombre de chiffres » ne passe de + toute façon pas par ici — sa bande est calculée en entiers exacts. + """ + try: + return float(valeur) + except (OverflowError, ValueError, TypeError): + return sys.float_info.max if valeur > 0 else -sys.float_info.max + + +def _bornes_par_signe(valeur, bornes): + """L'intervalle de tirage, du côté du signe de la valeur. + + Le signe est préservé ET le tirage reste dans l'étendue mesurée : ces + deux promesses ne tiennent ensemble qu'en découpant l'étendue au zéro. + Une valeur négative implique que le minimum mesuré l'est aussi, donc le + sous-intervalle négatif existe toujours quand on en a besoin. + """ + bas, haut = 0.0, 1000.0 + if bornes: + # Un troisième terme peut suivre — l'intégralité de la colonne —, + # que cette fonction ignore : elle ne décide que de l'intervalle. + mini, maxi = bornes[0], bornes[1] + if mini is not None and maxi is not None and maxi > mini: + bas, haut = _en_flottant(mini), _en_flottant(maxi) + if valeur > 0: + return max(bas, 0.0), haut if haut > 0 else 1000.0 + return (bas if bas < 0 else -1000.0), min(haut, 0.0) + + +# Essais aléatoires avant de passer au parcours des places. Dix suffisent +# tant que la plage est large ; le parcours tranche quand elle est étroite. +ESSAIS_UNICITE = 10 + +# Le parcours des places est borné : sur une plage de plusieurs milliers de +# valeurs, l'aléatoire a déjà répondu, et une plage vraiment saturée doit +# s'élargir plutôt que se faire fouiller. +PLACES_PARCOURUES = 8192 + +# Pas d'agrandissement, une fois la plage saturée. On grandit D'UN PAS à +# la fois, du côté qui s'éloigne du zéro : avec N valeurs dans N places et +# l'interdiction de rendre l'identité, la dernière place libre EST parfois +# l'identité, et la plage se sature sans être trop petite. Un taux qui +# passe de 0,20 à 0,21 reste un taux ; le même élargi dix fois ne l'est +# plus. +PAS_AGRANDIS = 32 + +# Paliers ×10, dernier recours quand même l'agrandissement ne suffit pas. +PALIERS_ELARGISSEMENT = 5 + + +def _doublon_du_meme_calibre(valeur, rng, bas, haut, entier, decimales): + """Un doublon, mais de la MÊME largeur — jamais la valeur d'origine. + + Quand la bande d'un calibre est saturée, l'unicité n'est plus tenable + et la largeur l'est encore. Une largeur changée se VOIT, et une + colonne bornée la refuse ; un doublon ne fait rien fuir. + + La bande d'un calibre compte toujours au moins neuf places — 1..9 + pour un chiffre, et davantage ensuite —, donc la sonde à trois + candidats en trouve une qui n'est pas l'origine. + """ + for _ in range(ESSAIS_UNICITE): + tire = _tirer(valeur, rng, bas, haut, entier, decimales) + if tire != valeur: + return tire + # Le tirage peut retomber sur l'origine à chaque essai : sonder les + # bornes tranche à coup sûr, là où parcourir une bande de dix chiffres + # coûterait le prix de la bande. + pas = 1 if entier else 10.0**-decimales + for brut in (bas, haut, bas + pas): + candidat = int(brut) if entier else round(brut, decimales) + if candidat != valeur and candidat != 0: + return candidat + return _tirer(valeur, rng, bas, haut, entier, decimales) + + +def _tirer_libre(valeur, rng, bas, haut, entier, pris, calibre=False): + """Un tirage dans une place LIBRE de l'étendue mesurée. + + Élargir dès le premier échec faisait sortir la valeur de la plage + mesurée alors qu'elle avait encore des places : une heure de la + journée devenait 189, un taux dépassait l'unité. L'élargissement n'est + plus qu'un dernier recours, quand la plage est vraiment saturée. + + `calibre` INTERDIT l'élargissement : la plage est alors la bande des + nombres de même largeur, et en sortir rend une valeur d'une autre + largeur — la seule chose que l'option promette. Les trois recours qui + élargissent cèdent la place à un doublon dans la bande. + + La valeur d'origine compte parmi les places prises : un nombre rendu à + lui-même serait compté et annoncé comme remplacé alors que la copie le + porte inchangé. + """ + # La résolution est celle de la COLONNE, jamais celle de la valeur : + # « 0,2 » en porte une et « 0,15 » deux, et suivre chaque valeur faisait + # arrondir la plage [0,15 ; 0,20] au dixième — donc sortir par le bas, + # à 0,1. Les bornes sont mesurées sur toute la colonne, elles sont le + # bon repère. + decimales = ( + 2 + if entier + else max(_decimales(bas), _decimales(haut), _decimales(valeur)) + ) + pas = 1 if entier else 10.0**-decimales + # Une étendue dont le quotient par le pas dépasse le flottant — un + # nombre au plafond d'Excel dans une colonne à deux décimales — + # rendait `inf`, et `int(round(inf))` levait. La plage est alors bien + # trop large pour se faire parcourir : la compter comme telle est la + # réponse, refuser la copie n'en est pas une. + try: + etendue = (haut - bas) / pas + except OverflowError: + # Une étendue en entiers exacts trop grande pour un flottant tombe + # ici plutôt que de rendre `inf` : même conclusion, la plage est + # bien trop large pour se faire parcourir. + etendue = math.inf + places = ( + int(round(etendue)) + 1 + if math.isfinite(etendue) + else PLACES_PARCOURUES + 1 + ) + interdit = (valeur,) + for _ in range(ESSAIS_UNICITE): + tire = _tirer(valeur, rng, bas, haut, entier, decimales) + if tire not in pris and tire not in interdit: + return tire + if 0 < places <= PLACES_PARCOURUES: + # Parcourir depuis un point au hasard : sans point de départ + # aléatoire, une plage étroite se remplirait toujours dans le même + # ordre et la copie deviendrait devinable. + depart = rng.randrange(places) + for decalage in range(places): + brut = bas + ((depart + decalage) % places) * pas + candidat = int(brut) if entier else round(brut, decimales) + if candidat == 0 or candidat in interdit: + continue + if candidat not in pris: + return candidat + # Saturée : grandir d'un pas à la fois, du côté qui s'éloigne du + # zéro, plutôt que de multiplier l'étendue par dix. Sous calibre + # ce recours n'existe pas : chaque pas sort de la bande. + vers_le_haut = haut > 0 + for rang in range(1, 1 if calibre else PAS_AGRANDIS + 1): + brut = (haut + rang * pas) if vers_le_haut else (bas - rang * pas) + candidat = int(brut) if entier else round(brut, decimales) + if candidat == 0 or candidat in interdit: + continue + if candidat not in pris: + return candidat + if calibre: + return _doublon_du_meme_calibre( + valeur, rng, bas, haut, entier, decimales + ) + for palier in range(1, PALIERS_ELARGISSEMENT + 1): + facteur = 10**palier + for _ in range(ESSAIS_UNICITE): + tire = _tirer( + valeur, + rng, + bas * facteur, + haut * facteur, + entier, + decimales, + ) + if tire not in pris and tire not in interdit: + return tire + # Toutes les places connues sont prises : rendre un doublon vaut mieux + # que refuser une copie propre — une collision ne fait rien fuir. Mais + # JAMAIS l'identité : elle laisse la valeur d'origine dans la copie en + # la comptant comme remplacée, ce qui est une fuite annoncée propre. + for _ in range(ESSAIS_UNICITE): + tire = _tirer(valeur, rng, bas, haut, entier, decimales) + if tire not in interdit: + return tire + ecart = 1 if entier else 10.0**-decimales + return valeur + ecart if valeur > 0 else valeur - ecart + + +def _decimales(valeur): + """Le nombre de décimales que porte cette valeur, au plus dix. + + Arrondir tout flottant à deux décimales laissait presque aucune place + à une colonne plus fine — un taux à sept décimales n'en avait qu'une + poignée — ce qui saturait la plage et forçait l'élargissement, lequel + brisait la promesse de rester dans l'étendue mesurée. + """ + try: + texte = repr(float(valeur)) + except OverflowError: + # Un entier de plusieurs centaines de chiffres n'a pas de partie + # décimale à compter. + return 0 + if "e" in texte or "E" in texte: + # `repr` passe en notation exposant sous 1e-4 : compter l'exposant + # plutôt que rendre 2. Un pas de 0,01 sur une étendue de 1e-7 ne + # laisse aucune place, et la colonne entière sort de la plage + # mesurée sur un seul nombre, le même quelle que soit la graine. + exposant = decimal.Decimal(texte).as_tuple().exponent + return min(max(-exposant, 2), 17) + _entier, _point, fraction = texte.partition(".") + return min(len(fraction.rstrip("0")) or 2, 10) + + +def _tirer(valeur, rng, bas, haut, entier, decimales=2): + """Un tirage dans l'intervalle, du même signe que la valeur. + + Un zéro tiré effacerait le signe que la règle promet de garder, et se + lirait comme une absence de valeur. + """ + if entier: + plancher, plafond = int(bas), int(haut) + if plafond <= plancher: + plafond = plancher + 1 + tire = rng.randint(plancher, plafond) + if tire == 0: + return 1 if valeur > 0 else -1 + return tire + tire = round(rng.uniform(bas, haut), decimales) + if tire == 0: + menu = 10.0**-decimales + return menu if valeur > 0 else -menu + return tire + + +def nouveau_nombre(valeur, rng, bornes=None, table=None, calibre=False): + """Un nombre du même signe, dans l'étendue MESURÉE de sa colonne. + + `calibre` échange cette étendue contre celle des nombres de MÊME + LARGEUR : 8839 tire alors dans 1000..9999. Les deux ne peuvent pas + tenir ensemble — l'étendue d'une colonne mêle des largeurs — et c'est + l'opérateur qui tranche, colonne par colonne étant hors de portée + d'une invite. + + « 0 à 1000 » était l'intention, et `anonymize.py` a mesuré que c'est + faux pour tout nombre qui porte un sens borné : un taux de 0,15 devenu + 743, une année devenue 12, une heure de la journée tirée à 957 qui fait + lever l'ORM. L'étendue réelle de la colonne l'emporte donc, et 0-1000 + ne sert plus que de repli pour une colonne vide ou constante. + + Zéro reste zéro : il n'a pas de signe à préserver, et un zéro qui + devient 743 fabrique de la donnée là où il n'y en avait pas. + + `table` donne au nombre l'intégrité référentielle que le texte a déjà : + sans elle, la même clé de jointure rend un nombre différent à chaque + ligne, et toute relation d'un export ou d'une base Access se + désagrège. + """ + if isinstance(valeur, bool) or valeur is None: + return valeur + if valeur == 0: + return valeur + # La clé de la table reste celle de la VALEUR : la faire dépendre de + # la colonne donnerait deux clés à un même nombre vu dans deux + # colonnes de résolutions différentes, et la jointure qui les relie se + # désagrégerait. + cle = f"{'i' if isinstance(valeur, int) else 'f'}:{valeur!r}" + # Le TIRAGE, lui, suit la colonne. `isinstance` ne peut pas en + # répondre : le format `.xls` ne stocke que des doubles, si bien que + # son lecteur rend 100 en `100.0` et que toute colonne d'entiers + # ressortait décimale — une copie qui ne se réimporte plus dans un + # champ entier. C'est la même leçon que pour la résolution : le type + # d'UNE valeur ne dit pas la nature de sa colonne. + entier = isinstance(valeur, int) + if bornes is not None and len(bornes) > 2 and bornes[2] is not None: + entier = bool(bornes[2]) + if table is not None: + connu = table.nombres.get(cle) + if connu is not None: + return connu + bas, haut = _bornes_par_signe(valeur, bornes) + # Sous l'unité, `bornes_du_meme_calibre` rend None et l'étendue mesurée + # gouverne comme d'habitude — élargissement compris. Ce n'est donc pas + # l'OPTION qui interdit d'élargir, mais le fait qu'une bande s'applique. + en_bande = False + if calibre: + meme = bornes_du_meme_calibre(valeur) + if meme is not None: + # Les bornes restent ENTIÈRES. Passer par `float` élargissait + # la bande d'un chiffre dès seize : `float(10**16 - 1)` + # remonte à 10**16, et `int()` le garde. La largeur est la + # seule chose que l'option promette. + bas, haut = meme + en_bande = True + # SANS REMISE, comme pour le texte. Le tirage seul collisionne par le + # paradoxe des anniversaires : mesuré, 100 valeurs distinctes dans une + # étendue de 100 ne rendent que 66 sorties distinctes. Deux clés + # primaires qui reçoivent le même nombre font une fixture qui ne se + # réimporte plus — et c'est justement l'intégrité que la table apporte + # au texte, refusée en silence aux nombres. + pris = table.nombres_pris if table is not None else () + tire = _tirer_libre(valeur, rng, bas, haut, entier, pris, en_bande) + if table is not None: + table.nombres[cle] = tire + table.nombres_pris.add(tire) + return tire + + +# ---------------------------------------------------------------------- +# La portée +# ---------------------------------------------------------------------- +def colonne_plancher( + etiquette, + forme_identifiant=False, + forme_relation=False, + selection=False, +): + """Vrai si cette colonne porte un identifiant, non un nom. + + Le plancher s'applique AVANT la question des colonnes intactes et + indépendamment d'elle : sans lui, accepter tous les défauts détruit + `id`, `partner_id/id` et les relations, pour une entrée dont tout + l'objet est un jeu de test qui FONCTIONNE. + + Mais le NOM ne suffit pas à décider. `CHAMPS_INTERDITS` vient d'un + anonymiseur de BASE, où `display_name` est refusé parce que le serveur le + RECALCULE depuis `name` ; un fichier plat ne recalcule rien, la colonne + EST la donnée. Et dans un export import-compatible, `partner_id` porte le + nom affiché de la relation, pas un entier. `forme_identifiant` dit si le + CONTENU mesuré de la colonne a la forme d'un identifiant ; sans lui, le + plancher recopie en clair les colonnes les plus identifiantes du fichier + et l'annonce comme une protection. + """ + if not etiquette: + return False + bas = str(etiquette).strip().lower() + if not bas: + return False + if bas in PLANCHER_STRUCTUREL: + return True + if any(bas.endswith(s) for s in SUFFIXES_STRUCTURELS): + return True + # Une relation exige la forme d'une CIBLE de relation : un entier, un + # chemin d'ids, un external ID. Un mot en minuscules n'en est pas une. + if any(bas.endswith(s) for s in SUFFIXES_IDENTIFIANTS): + return bool(forme_relation) + if bas in ETIQUETTES_POINTEES: + return bool(forme_relation) + if bas in ETIQUETTES_SELECTION: + # La forme SEULE accepterait n'importe quel mot minuscule : une + # colonne de provinces, ou de créneaux nommés par des personnes. + return bool(forme_identifiant) and bool(selection) + return False + + +def cellule_en_portee(feuille, ligne, colonne, options): + """La portée se décide sur les COORDONNÉES, jamais sur la valeur. + + `feuille` est un nom, `None` hors tableur. `ligne` et `colonne` sont + des entiers 1-based, comme openpyxl les compte. + """ + # Une colonne de STRUCTURE : les noms de balise d'un XML, les clés + # aplaties d'un JSON. Le graveur ne les touche jamais, et les compter + # comme remplacées désarmait le refus « rien à faire » et brûlait le + # vivier sur des noms de champ. + if (feuille, colonne) in (options.get("colonnes_structure") or ()): + return False + # Une LIGNE de structure : les clés d'objet, l'étiquette que le lecteur + # a fabriquée. Rien ne peut l'écrire, donc la compter en portée annonce + # un remplacement que la copie ne porte pas. + if (feuille, ligne) in (options.get("lignes_structure") or ()): + return False + feuilles = options.get("feuilles") + if feuilles and feuille is not None and feuille not in feuilles: + return False + if not options.get("entetes"): + # L'ABSENCE de la clé vaut « la ligne 1 est l'en-tête » — le + # comportement d'avant la mesure. Un appelant qui ne mesure pas + # (le gabarit d'options des tests de portée ne porte que quelques + # clés) mettrait sinon la ligne de champs en portée : ses libellés + # remplacés, la copie illisible, et une RECHERCHEV du destinataire + # résolue sur la mauvaise ligne. + # + # Une clé PRÉSENTE et vide veut dire « cette feuille n'a pas + # d'en-tête », et sa ligne 1 entre en portée. C'est là qu'une + # première ligne de DONNÉES cesse d'être recopiée en clair. + empan = options.get("lignes_entete") + if empan is None: + if ligne == 1: + return False + elif (feuille, ligne) in empan: + return False + etiquette = (options.get("etiquettes") or {}).get((feuille, colonne)) + formes = options.get("formes") or {} + forme = formes.get((feuille, colonne), False) + forme_rel = (options.get("formes_relation") or {}).get( + (feuille, colonne), False + ) + selection = (options.get("selections") or {}).get( + (feuille, colonne), False + ) + if colonne_plancher(etiquette, forme, forme_rel, selection): + return False + return not colonne_repondue(feuille, colonne, etiquette, options) + + +def colonne_repondue(feuille, colonne, etiquette, options): + """L'opérateur a-t-il demandé de laisser CETTE colonne intacte ? + + Deux ensembles, consultés dans cet ordre : celui de la FEUILLE, puis + le plat. Le plat était seul, et il est global : répondre « 3 » gelait + la colonne 3 des dix feuilles d'un classeur. Un écran qui laisse + cocher la colonne 3 de la septième feuille tiendrait donc une promesse + fausse — et l'erreur va du mauvais côté, neuf feuilles restant + sous-anonymisées. + + L'index ne répond QUE pour une colonne sans étiquette. Sinon « 1 » + désigne à la fois la colonne étiquetée « 1 » et la première colonne, + et une seule réponse en épargne deux — dont celle des noms, que + l'aperçu n'annonçait pas. + + Un ÉCRAN, lui, désigne par l'index sans ambiguïté : il tient l'objet + colonne, il ne tape pas une chaîne. Et l'index est la seule désignation + qui survit à une correction d'en-tête, qui RENOMME les colonnes — + répondre par l'étiquette faisait tomber la réponse sur une autre + colonne, ou sur aucune, en silence. D'où deux ensembles distincts + plutôt qu'un : des entiers pour l'écran, des chaînes pour l'invite. + + La règle vit ICI et nulle part ailleurs : `_colonnes_ecartees` et + `_colonnes_saturees` la reprenaient chacune à sa façon, ce qui fait + trois occasions de divergence. + """ + par_index = (options.get("colonnes_intactes_index_par_feuille") or {}).get( + feuille + ) + if par_index and colonne in par_index: + return True + if etiquette is not None and str(etiquette).strip(): + reponse = str(etiquette).strip() + else: + reponse = str(colonne) + par_feuille = (options.get("colonnes_intactes_par_feuille") or {}).get( + feuille + ) + if par_feuille and reponse in par_feuille: + return True + return reponse in (options.get("colonnes_intactes") or set()) + + +def anonymise_cellule(valeur, options, table, rng, bornes=None): + """La valeur de remplacement, ou `_INTACTE` si la cellule ne bouge pas. + + L'ordre des tests est la règle elle-même. Chaque garde ferme un piège + que le suivant ne verrait pas. + """ + if valeur is None or valeur == "": + return _INTACTE + # `isinstance(True, int)` vaut True : sans cette ligne d'abord, toute + # case à cochée deviendrait un montant. + if isinstance(valeur, bool): + return _INTACTE + # Un document, une pièce jointe : jamais recopié, toujours vidé. + if isinstance(valeur, (bytes, bytearray)): + return None + if isinstance(valeur, str): + texte = valeur + if texte.startswith("="): + return _INTACTE + if texte in VALEURS_ERREUR: + return _INTACTE + if not options.get("texte", True): + return _INTACTE + return nouveau_mot(texte, table, options["vivier"]) + if isinstance(valeur, (int, float)): + if not options.get("nombres", True): + return _INTACTE + return nouveau_nombre( + valeur, + rng, + bornes=bornes, + table=table, + calibre=bool(options.get("calibre_chiffres")), + ) + # `datetime`, `date`, `time` et tout objet d'un lecteur : intacts. Les + # tirer au hasard casserait les tris et les échéances. + return _INTACTE + + +class _Intacte: + """Le témoin « cette cellule ne bouge pas ». + + `None` ne peut pas jouer ce rôle : vider une cellule EST une décision + (une colonne binaire d'Access), et il faut la distinguer de « ne pas y + toucher ». + """ + + __slots__ = () + + def __repr__(self): # pragma: no cover - confort de débogage + return "INTACTE" + + +_INTACTE = _Intacte() + + +def classer(valeur): + """La famille d'une valeur, pour les comptes du rapport.""" + if valeur is None or valeur == "": + return "vide" + if isinstance(valeur, bool): + return "booleen" + if isinstance(valeur, (bytes, bytearray)): + return "binaire" + if isinstance(valeur, str): + if valeur.startswith("="): + return "formule" + if valeur in VALEURS_ERREUR: + return "erreur" + return "texte" + if isinstance(valeur, (int, float)): + return "nombre" + return "date" + + +# ---------------------------------------------------------------------- +# La sérialisation hors tableur +# ---------------------------------------------------------------------- +def valeur_hors_tableur(valeur): + """La valeur telle qu'elle s'écrit en CSV, JSON ou XML. + + Ces trois formats ne portent aucun type d'openpyxl. Sans ce passage, + un `csv.writer` GRAVE l'adresse mémoire d'un `ArrayFormula` dans le + fichier — l'objet ne définit pas `__str__` — et `json.dumps` lève sur + la première date rencontrée. + + Le texte de formule n'est PAS préfixé d'une apostrophe : l'apostrophe + n'est pas une échappe CSV, `csv.reader` la rend dans la valeur. + """ + if valeur is None: + return None + if isinstance(valeur, bool): + return valeur + if isinstance(valeur, (int, float, str)): + return valeur + texte = getattr(valeur, "text", None) + if isinstance(texte, str): + return texte + iso = getattr(valeur, "isoformat", None) + if callable(iso): + return iso() + return str(valeur) + + +def nom_de_fichier_sur(nom, pris, maximum=None): + """Un nom de feuille ou de table, rendu sûr comme nom de fichier. + + `pris` est l'ensemble des noms déjà attribués : deux feuilles qui se + réduisent au même après nettoyage doivent rester deux fichiers. + + `maximum` borne la longueur — un onglet Excel n'en accepte que 31. + La coupe vient AVANT l'unicité, et le candidat suffixé est revérifié : + unicifier d'abord puis couper faisait retomber deux noms distincts sur + le même, et le classeur perdait une feuille en silence. + """ + propre = re.sub(r"[^A-Za-z0-9._-]", "_", str(nom or "")) + propre = propre.strip("_") + if not propre or set(propre) <= {"_", ".", "-"}: + propre = f"feuille_{len(pris) + 1}" + if maximum: + propre = propre[:maximum].rstrip("._-") or f"feuille_{len(pris) + 1}" + candidat = propre + suffixe = 1 + while candidat in pris: + suffixe += 1 + marque = f"_{suffixe}" + base = propre[: maximum - len(marque)] if maximum else propre + candidat = f"{base}{marque}" + pris.add(candidat) + return candidat + + +# Un nombre écrit en toutes lettres décimales, et rien d'autre. Le motif +# REFUSE délibérément : les zéros de tête (« 007 » est un code, pas une +# quantité, et le tourner en 7 lui ôte son sens), la notation +# exponentielle, « NaN » et « inf » — que `float()` accepte pourtant — et +# les séparateurs de milliers. +_NOMBRE_TEXTE = re.compile(r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?") + + +def coercer_texte(valeur): + """Le type d'un champ qui arrive en chaîne faute de mieux. + + `csv.reader` ne rend QUE des chaînes, et un attribut XML non plus n'a + pas de type. Sans ce passage, une colonne de montants est vue comme du + texte : chaque montant devient un mot, la colonne perd ses bornes, et + le fichier produit ne se réimporte plus ni ne s'additionne — ce qui + vide de son sens un jeu de test. + + Le doute profite à la chaîne : ce qui n'est pas franchement un nombre + en reste une, et reçoit un mot. + """ + if not isinstance(valeur, str): + return valeur + texte = valeur.strip() + if not texte or not _NOMBRE_TEXTE.fullmatch(texte): + return valeur + return float(texte) if "." in texte else int(texte) + + +# La vérification ne regarde pas les chaînes trop courtes : « 12 » ou « ok » +# apparaissent dans n'importe quel XML de conteneur et noieraient le signal. +LONGUEUR_VERIFIABLE = 4 + +# Le socle du graveur, lu une seule fois. +_SOCLE = None + + +def valeurs_a_verifier(table): + """Les chaînes que le moteur a DIT avoir remplacées. + + `table.mots` est exactement l'ensemble des valeurs texte vues EN + PORTÉE : `nouveau_mot` n'est appelé nulle part ailleurs. Une de ces + valeurs qui subsiste dans la copie est donc une fuite sans ambiguïté — + le moteur a annoncé son remplacement et une copie en a survécu + ailleurs, dans un cache, un nom de colonne de tableau ou une feuille + qu'on croyait retirée. + + Ce qui est hors portée n'est PAS regardé ici : ces valeurs restent par + décision, et c'est `colonnes_ecartees` et `entete_gardee` qui les + nomment à l'écran. Les mêler ici rendrait la garde bruyante au point + d'être désactivée, ce qui est la seule manière de la rendre inutile. + """ + return { + valeur + for valeur in table.mots + if isinstance(valeur, str) + and len(valeur.strip()) >= LONGUEUR_VERIFIABLE + } + + +def _socle_du_graveur(): + """Le texte que le graveur écrit TOUJOURS, quel que soit le contenu. + + Un classeur vide porte déjà « Microsoft », « Calibri », « Normal », + « office ». Sans ce socle, une cellule qui porte un de ces mots refuse + la copie pour toujours, en nommant une partie sur laquelle l'opérateur + ne peut rien. Compter les occurrences EN SURPLUS du socle garde le + balayage aveugle aux vecteurs sans le rendre inutilisable. + """ + global _SOCLE + if _SOCLE is not None: + return _SOCLE + _SOCLE = {} + try: + import openpyxl + except ImportError: + return _SOCLE + import tempfile + + try: + with tempfile.TemporaryDirectory() as dossier: + temoin = os.path.join(dossier, "socle.xlsx") + openpyxl.Workbook().save(temoin) + with zipfile.ZipFile(temoin) as archive: + for nom in archive.namelist(): + _SOCLE[nom] = archive.read(nom).decode("utf-8", "ignore") + except (OSError, zipfile.BadZipFile): + _SOCLE = {} + return _SOCLE + + +# Le texte d'un nœud XML, et la valeur d'un attribut. Une valeur de cellule +# vit TOUJOURS dans l'un des deux : le balisage ne peut pas la traverser. +_TEXTE_XML = re.compile(r">([^<>]+)<") +_ATTRIBUT_XML = re.compile(r"=\"([^\"]*)\"|='([^']*)'") + + +def _est_xml(nom, brut): + """Cette partie est-elle du XML ? + + C'est la NATURE de la partie qui doit décider de la matière fouillée, + jamais le fait que deux expressions y aient capturé quelque chose. Un + seul couple « > … < » dans un fichier plat — un fragment HTML dans une + colonne gardée suffit — ramenait sinon le balayage à ce qui les sépare, + et toute valeur hors de cet intervalle sortait sans refus. + """ + if nom.endswith((".xml", ".rels", ".vml")): + return True + # Le préfixe seulement : une partie fait plusieurs mégaoctets, et + # `brut.lstrip()` la recopierait à chaque appel. Le BOM est dans + # l'ensemble à retirer, pour qu'un XML qui en porte reste reconnu. + tete = brut[:512].lstrip("\ufeff \t\r\n") + return tete.startswith("` d'une + feuille de plus de mille lignes. Une valeur de cellule, elle, vit + dans un nœud de texte — `` —, donc restreindre la recherche là + n'abandonne rien de ce que le filet doit voir. Énumérer plutôt les + attributs de structure serait à refaire au premier format inconnu. + """ + ecrites = set() + nues = set() + texte_ecrites = set() + texte_nues = set() + + def ajouter(morceau, noeud_de_texte): + # Un `.xlsx` est un zip de XML : la valeur y est ÉCHAPPÉE. + # Chercher les octets bruts d'un nom portant « & », « < » ou « > » + # n'y trouve rien, et la copie part avec. + nu = html.unescape(morceau) if "&" in morceau else morceau + ecrites.add(morceau) + nues.add(nu) + if noeud_de_texte: + texte_ecrites.add(morceau) + texte_nues.add(nu) + + for morceau in _TEXTE_XML.findall(brut): + ajouter(morceau, True) + for double, simple in _ATTRIBUT_XML.findall(brut): + for morceau in (double, simple): + if morceau: + ajouter(morceau, False) + return ecrites, nues, texte_ecrites, texte_nues + + +def _matiere(nom, brut, reductible=True): + """Quatre blocs : écrit, déséchappé, puis les mêmes en TEXTE seul. + + Du XML se réduit à ses chaînes distinctes ; tout le reste est fouillé + ENTIER. + + Sur une partie qui n'est pas du XML, les deux derniers blocs sont les + deux premiers : un champ de csv est de la donnée où qu'il soit, et + l'y restreindre aveuglerait le filet sur le format le plus simple. + + `reductible=False` pour une copie PLATE : elle est UNE seule partie, la + réduire n'achète rien, et le contenu ne peut pas décider de la + couverture. Un export d'ERP qui est du HTML sous une extension `.csv` + ou `.txt` commence par « < » et passerait pour du XML : le balayage se + réduirait alors au premier nœud, et tout le reste du fichier sortirait + sans refus. + """ + if reductible and _est_xml(nom, brut): + ecrites, nues, t_ecrites, t_nues = _chaines_distinctes(brut) + return ( + _joindre(ecrites), + _joindre(nues), + _joindre(t_ecrites), + _joindre(t_nues), + ) + # Un graveur de fichier plat ÉCHAPPE : `csv` double le guillemet d'une + # valeur qui en porte un, `json.dump` le préfixe d'une barre oblique. + # Chercher les octets bruts d'un nom portant un guillemet n'y trouvait + # alors rien. On déchiffre le FOIN une fois, plutôt que de réencoder + # chaque aiguille — les parties d'un zip restent intactes. + vues = {brut} + if "&" in brut: + vues.add(html.unescape(brut)) + if '""' in brut: + vues.add(brut.replace('""', '"')) + if "\\" in brut: + vues.add( + brut.replace('\\"', '"').replace("\\/", "/").replace("\\\\", "\\") + ) + tout = _joindre(vues) + return brut, tout, brut, tout + + +# Le préfiltre : un bit par empreinte de n-gramme. 2^22 bits font 512 Kio, +# quelle que soit la taille du document. Il ne rend JAMAIS de faux négatif — +# c'est ce qui autorise à s'y fier pour écarter une valeur — et ses faux +# positifs retombent sur le comptage exact, qui tranche. +_BITS_PREFILTRE = 1 << 22 +_MASQUE_PREFILTRE = _BITS_PREFILTRE - 1 + +# En deçà, le comptage direct est déjà plus rapide que la construction du +# préfiltre. Le seuil n'est pas un réglage fin : il sépare « quelques +# centaines de valeurs » de « des dizaines de milliers ». +SEUIL_PREFILTRE = 2000 + + +def _prefiltre(bloc): + """Les empreintes des n-grammes du bloc, en bitmap. + + Le balayage cherche des dizaines de milliers de valeurs dans un + document où elles sont justement ABSENTES : chaque recherche parcourt + tout le bloc pour ne rien trouver, et le coût est le produit des deux + tailles. Une valeur ne peut apparaître que si son premier n-gramme + apparaît ; le vérifier coûte un accès, et écarte presque tout. + """ + bits = bytearray(_BITS_PREFILTRE >> 3) + taille = LONGUEUR_VERIFIABLE + for depart in range(len(bloc) - taille + 1): + empreinte = hash(bloc[depart : depart + taille]) & _MASQUE_PREFILTRE + bits[empreinte >> 3] |= 1 << (empreinte & 7) + return bits + + +def _peut_contenir(bits, valeur): + """Faux si la valeur ne peut PAS être dans le bloc. Jamais l'inverse.""" + if bits is None or len(valeur) < LONGUEUR_VERIFIABLE: + return True + empreinte = hash(valeur[:LONGUEUR_VERIFIABLE]) & _MASQUE_PREFILTRE + return bool(bits[empreinte >> 3] & (1 << (empreinte & 7))) + + +def _joindre(chaines): + """Un bloc unique, les chaînes séparées par un octet nul. + + L'octet nul n'apparaît dans aucun document : il empêche une valeur de + se former à cheval sur deux chaînes voisines, ce qu'une simple + concaténation permettrait. Trié pour que deux exécutions rendent le + même bloc. + """ + return "\x00".join(sorted(c for c in chaines if c)) + + +_MOTIF_TOUT_CHIFFRE = re.compile(r"^[0-9]+$") + + +def _motif_borne(valeur): + """Le motif d'une valeur PUREMENT numérique, ou None. + + Une valeur de chiffres est indiscernable, en sous-chaîne, des chiffres + qui vivent légitimement ailleurs : un code postal « 0512 » se retrouve + dans l'identifiant 10512, « 1081 » dans 10815. Sur une base ordinaire + cela suffit : seize valeurs faisaient refuser une copie saine, sans + qu'aucune ne fuie. + + Le remède n'affaiblit rien : une VRAIE survivance est bordée de ce qui + n'est pas un chiffre — `>0512<`, `"0512"` —, donc elle est toujours + vue. Ce qui cesse de compter est la valeur courte NOYÉE dans un nombre + plus long, qui n'en est jamais une occurrence. + + Bordent : les chiffres, les LETTRES et le point. Les lettres, parce + qu'un attribut de référence de cellule — `r="A1010"` — met un numéro + de LIGNE à côté d'une lettre de colonne, et que la feuille de plus de + mille lignes fait alors refuser toute valeur à quatre chiffres ; plus + généralement, des chiffres collés à une lettre font un seul jeton, et + une vraie survivance porterait la lettre dans sa valeur. Le point, + parce que « 1203 » dans 1203,5 est un autre nombre. + + Ne bordent PAS : la virgule, qui SÉPARE les champs d'un csv — l'y + mettre aveuglait le filet sur le format le plus simple, là où un + séparateur de milliers coupe déjà la suite de chiffres et ne pose donc + pas le problème qu'on croyait. Ni le signe moins : mêmes chiffres, et + refuser est le côté sur lequel pencher. + """ + if _MOTIF_TOUT_CHIFFRE.match(valeur): + return re.compile(r"(?= SEUIL_PREFILTRE + bits_garde = _prefiltre(bloc_garde) if assez else None + filtres = [ + ( + nom, + paire, + paire_socle, + _prefiltre("\x00".join(paire)) if assez else None, + ) + for nom, paire, paire_socle in morceaux + ] + + trouvees = {} + for valeur in valeurs: + # `str.count` est du C : c'est ce qui rend le balayage tenable. + # Une boucle Python sur les chaînes coûtait cinquante secondes pour + # vingt mille valeurs, là où le compte sur un bloc joint en prend + # une fraction — même travail, même grain. + # Annoncée gardée À L'IDENTIQUE : ce n'est pas une fuite, où + # qu'elle reparaisse. Une cellule hors portée, un titre de feuille, + # un littéral de formule sont déjà dans la copie en clair, et une + # occurrence de plus ne divulgue rien de neuf — c'est ce que le + # comptage par occurrence refusait à tort. + if valeur in tolerees_exactes: + continue + motif = _motif_borne(valeur) + # Tolérée seulement comme PARTIE d'une chaîne annoncée : le compte + # tranche, sinon une chaîne gardée qui contient la valeur la + # couvrirait même là où elle fuit. + excuses = ( + _compter(bloc_garde, valeur, motif) + if _peut_contenir(bits_garde, valeur) + else 0 + ) + vus = 0 + parties = [] + for nom, paire, paire_socle, bits in filtres: + if not _peut_contenir(bits, valeur): + continue + # Le MAX des deux vues, jamais leur somme : une même chaîne + # portant « & » apparaît dans les deux, et l'additionner + # gonflait le compte au-delà de la tolérance annoncée. + # Une valeur de chiffres n'est cherchée que dans les nœuds + # de texte, où vit une valeur de cellule ; ailleurs, partout. + vues = paire[2:] if motif is not None else paire[:2] + compte = max(_compter(v, valeur, motif) for v in vues) + if not compte: + continue + vus += compte + # EN SURPLUS du socle : « Normal » que le graveur écrit + # toujours dans `xl/styles.xml` n'est pas une fuite ; une + # seconde occurrence en est une. + # Le socle se compte par les MÊMES vues et la même règle de + # bornes : comparer un compte borné à un compte non borné + # laisserait l'excuse et l'occurrence parler de choses + # différentes. + socle_vues = ( + paire_socle[2:] if motif is not None else paire_socle[:2] + ) + excuses += max(_compter(v, valeur, motif) for v in socle_vues) + if nom not in parties: + parties.append(nom) + if vus > excuses: + trouvees[valeur] = parties + return trouvees + + +def verifier_copie(fichiers, table, gardees=()): + """Relire les octets écrits, et refuser la copie qui porte la source. + + C'est un filet, non la règle : les règles décident ce qu'on remplace, + et cette fonction constate ce qui est SORTI. Sa valeur est de ne + dépendre d'aucune énumération de vecteurs — un endroit du format que + personne n'a pensé à nettoyer produit un refus, là où une liste de + parties à vérifier produirait un silence. + + `gardees` est ce que le moteur conserve SCIEMMENT et a annoncé : noms de + feuille, plages nommées, littéraux de formule, clés d'objet, noms de + balise. Tout le reste qui subsiste est un refus. + + Rend (survivances non annoncées, nombre de valeurs non regardées). + """ + valeurs = valeurs_a_verifier(table) + tolerees = {str(g) for g in gardees if isinstance(g, str) and g.strip()} + # Un mot du vivier attribué à une AUTRE valeur reparaît dans la copie + # comme remplacement, non comme survivance. `cle != mot` garde le cas de + # l'identité — une valeur rendue à elle-même — comme un refus. + tolerees.update( + str(mot) + for cle, mot in getattr(table, "mots", {}).items() + if isinstance(mot, str) and str(cle) != str(mot) + ) + # Le même raisonnement pour les NOMBRES, qui n'en bénéficiaient pas : + # un nombre tiré pour une colonne peut égaler, chiffre pour chiffre, + # une valeur texte d'une autre colonne — un identifiant tiré à 10785 + # et un code postal « 10785 ». Sa présence est expliquée par le + # tirage, non par une survivance, et sans cette ligne une copie saine + # se faisait refuser. + tolerees.update( + str(nombre) + for cle, nombre in (getattr(table, "nombres", {}) or {}).items() + if str(cle) != str(nombre) + ) + # AUCUNE troncature, et pas de plafond. `candidates[:N]` d'une liste + # TRIÉE fait suivre la couverture à l'alphabet plutôt qu'au risque : de + # trois colonnes de texte, seule la première est relue, de façon + # déterministe, donc une reprise n'y change rien — et l'écran annonce + # une écriture propre. Le coût que ce plafond épargnait est de l'ordre + # de six secondes pour 24 000 valeurs sur neuf parties. + candidates = sorted(valeurs) + ecartees = 0 + fuites = {} + for fichier in fichiers: + if not fichier or not os.path.isfile(fichier): + continue + for valeur, parties in survivances( + fichier, set(candidates), tolerees + ).items(): + fuites.setdefault(valeur, []).extend( + f"{os.path.basename(fichier)}:{p}" for p in parties + ) + return fuites, ecartees + + +# Les types déclarés d'Access dont la valeur arrive en CHAÎNE. Comme pour +# `.xls`, c'est le TYPE qui décide et jamais la forme de la valeur : une +# colonne de texte peut légitimement porter « AAAA-MM-JJ hh:mm:ss », et la +# convertir en date la mettrait hors d'atteinte de la règle du texte. +ACCESS_DATETIME = 8 +ACCESS_MONETAIRE = frozenset({5, 16}) + +_MOTIF_DATE_ACCESS = re.compile( + r"(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})" +) + + +def normaliser_access(valeur, type_colonne): + """La valeur d'une cellule Access, ramenée aux types de la règle. + + `access-parser` rend une date par `str(datetime)` et un montant par sa + représentation localisée (« $1,995.50 ») : sans cette normalisation, + une colonne de dates et une colonne monétaire sont vues comme du + texte, chaque valeur devient un mot, la colonne perd ses bornes, et la + copie ne se réimporte plus. + """ + if not isinstance(valeur, str): + return valeur + texte = valeur.strip() + if not texte: + return None + if type_colonne == ACCESS_DATETIME: + # Le marqueur d'Access pour une date qu'il ne sait pas représenter. + # Il ne porte aucune donnée : la vider vaut mieux que la remplacer + # par un mot, qui la ferait passer pour du texte du client. + if texte == "(Invalid Date)": + return None + trouve = _MOTIF_DATE_ACCESS.fullmatch(texte) + if not trouve: + return None + try: + return datetime.datetime(*(int(g) for g in trouve.groups())) + except ValueError: + return None + if type_colonne in ACCESS_MONETAIRE: + # `eE+` sont gardés : la branche scientifique de la bibliothèque + # rend « 3.24e+01 », et retirer l'exposant en faisait 3,2401. + nu = re.sub(r"[^0-9.,()eE+-]", "", texte) + negatif = nu.startswith("(") and nu.endswith(")") + nu = nu.strip("()").replace(",", "") + try: + nombre = float(nu) + except ValueError: + return valeur + if "." not in nu: + # `access-parser` a DEUX sorties pour une colonne monétaire. + # Reconnaît-il le format de la colonne, il place le point + # décimal et rend « $14.00 » ; ne le reconnaît-il pas, il rend + # l'entier de stockage TEL QUEL — et Access garde un Currency + # en entier multiplié par dix mille. Un fret de 47,42 arrivait + # donc à 474200, la colonne prenait des bornes gonflées de + # quatre ordres de grandeur, et la copie portait un fret à six + # chiffres. Les deux formes cohabitent dans un même fichier. + # + # Le point décimal tranche sans deviner : toutes les branches + # qui aboutissent en insèrent un, celle qui renonce n'en met + # pas. Le diviseur est celui de la bibliothèque, qui coupe les + # quatre derniers chiffres. + nombre /= 10000.0 + return -nombre if negatif else nombre + return valeur + + +def normaliser_xls(ctype, valeur, datemode): + """La valeur d'une cellule `.xls`, ramenée aux types de la règle. + + `xlrd` porte le type dans `ctype` et non dans la valeur : sans cette + normalisation, une date sort en flottant, un booléen en entier, une + cellule vide en chaîne vide, et une erreur en ENTIER — et les règles du + nombre et du texte les prennent pour ce qu'ils ne sont pas. + """ + if ctype in (0, 6): + return None + if ctype == 3: + from xlrd.xldate import xldate_as_datetime + + return xldate_as_datetime(valeur, datemode) + if ctype == 4: + return bool(valeur) + if ctype == 5: + return CODES_ERREUR_XLS.get(valeur, "#N/A") + return valeur + + +def main( + argv=None, +): # pragma: no cover - couvert par les tests de bout en bout + """Le point d'entrée. stdout ne porte QUE l'objet JSON du résultat.""" + from script.data import external_file_formats as formats + + analyseur = argparse.ArgumentParser(add_help=True) + analyseur.add_argument("--report", metavar="CHEMIN") + analyseur.add_argument("--plan", metavar="CHEMIN") + analyseur.add_argument("--apply", metavar="CHEMIN") + analyseur.add_argument("--out", metavar="CHEMIN") + analyseur.add_argument("--options", metavar="JSON", default="{}") + analyseur.add_argument("--table", metavar="CHEMIN") + analyseur.add_argument("--capabilities", action="store_true") + args = analyseur.parse_args(argv) + + def rendre(objet, code=0): + # `allow_nan=False` : json.dump émet sinon « NaN » et « Infinity » + # nus, que la norme JSON interdit. L'appelant les relirait — Python + # les accepte — mais tout autre lecteur du résultat le refuserait, + # et le moteur aurait écrit un document non conforme en annonçant + # un succès. Mieux vaut lever ici, là où la cause est visible. + json.dump(objet, sys.stdout, ensure_ascii=False, allow_nan=False) + sys.stdout.write("\n") + return code + + def echec(cle, detail="", conseil=""): + objet = {"erreur": ERREURS.get(cle, cle), "detail": detail} + if conseil: + objet["conseil"] = conseil + return rendre(objet, 1) + + if args.capabilities: + return rendre(formats.capabilities()) + + chemin = args.report or args.plan or args.apply + if not chemin: + return echec("format_inconnu", "") + souci = verifier_source(chemin) + if souci: + return echec(souci, chemin) + + try: + if args.report: + return rendre(formats.report(chemin)) + options = json.loads(args.options or "{}") + options["table_chemin"] = args.table + if args.plan: + return rendre(formats.plan(chemin, options)) + if not args.out: + return echec("format_inconnu", "--out") + return rendre(formats.ecrire(chemin, args.out, options)) + except formats.ErreurMoteur as exc: + return echec(exc.cle, exc.detail, getattr(exc, "conseil", "")) + except Exception as exc: # pragma: no cover - filet de dernier recours + import traceback + + traceback.print_exc(file=sys.stderr) + return echec("format_inconnu", f"{type(exc).__name__}: {exc}") + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/script/data/external_file_formats.py b/script/data/external_file_formats.py new file mode 100644 index 0000000..1deec99 --- /dev/null +++ b/script/data/external_file_formats.py @@ -0,0 +1,3654 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les formats : lire, décrire, nettoyer, écrire, convertir. + +Séparé d'`external_file.py`, qui porte les RÈGLES. La frontière n'est pas +esthétique : `external_file.py` doit s'importer sous `.venv.erplibre`, qui +n'a ni openpyxl ni xlrd, sinon les tests des règles pures tombent tous à +l'import. Ici, chaque bibliothèque tierce est importée DANS la fonction qui +en a besoin, pour la même raison. + +Le contrat externe reste `external_file.py --report/--plan/--apply` : la +coupure est interne. +""" + +from __future__ import annotations + +import collections +import csv +import datetime +import io +import json +import math +import os +import re +import sys +import tempfile +import warnings +import xml.etree.ElementTree as ET + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.data import external_file as noyau # noqa: E402 +from script.data.external_file import ( # noqa: E402 + _INTACTE, + Correspondance, + anonymise_cellule, + cellule_en_portee, + classer, + coercer_texte, + colonne_plancher, + detect_format, + format_divergent, + has_macros, + nom_de_fichier_sur, + normaliser_access, + normaliser_xls, + progres, + valeur_forme_identifiant, + valeur_hors_tableur, + verifier_copie, + vivier_de_mots, +) + +# openpyxl avertit sur stderr (« DrawingML support is incomplete »), et +# stderr est le canal de progression : un avertissement s'y lirait comme +# une ligne de progrès. +warnings.simplefilter("ignore") + +CIBLES_CONVERSION = ("xlsx", "csv", "json", "xml") + +# Le nom de feuille d'un format qui n'en porte pas. Reprendre le nom du +# FICHIER le recrachait dans la copie convertie — clé de premier niveau d'un +# JSON, nom d'onglet d'un xlsx — alors que le dialogue promet que le nom du +# fichier n'est pas anonymisé. +NOM_FEUILLE_NEUTRE = "feuille_1" + +# `csv` refuse un champ de plus de 128 Kio par défaut, et un mémo de +# commande les dépasse. Le refus arrivait en « format non reconnu » sur un +# CSV parfaitement valide. +csv.field_size_limit(16 * 1024 * 1024) + + +class ErreurMoteur(Exception): + """Un refus motivé, porteur d'une clé d'`ERREURS`. + + `conseil` est une SECONDE clé, celle du remède, quand le refus en a un + que l'opérateur ne devinerait pas. Le détail nomme ce que le moteur + trouve, le conseil dit quoi répondre à la prochaine exécution ; les + mêler dans une chaîne les rend intraduisibles tous les deux. + """ + + def __init__(self, cle, detail="", conseil=""): + super().__init__(cle) + self.cle = cle + self.detail = detail + self.conseil = conseil + + +# ---------------------------------------------------------------------- +# Ce que cette machine sait lire +# ---------------------------------------------------------------------- +def _importable(nom): + try: + __import__(nom) + return True + except Exception: + return False + + +def capabilities(): + """Format -> lisible ici. Les formats stdlib le sont toujours.""" + excel = _importable("openpyxl") + return { + "csv": True, + "json": True, + "xml": True, + "xlsx": excel, + "xls": _importable("xlrd"), + "xlsb": False, + "access": _importable("access_parser"), + "images": _importable("PIL"), + "mots": _importable("randomwordfr"), + } + + +# ---------------------------------------------------------------------- +# La grille — la forme commune à tous les lecteurs +# ---------------------------------------------------------------------- +class Feuille: + """Un tableau nommé : des lignes de valeurs, déjà normalisées. + + Chaque lecteur ramène ses types aux types des règles AVANT de rendre + une feuille. C'est là que se joue la correction d'un `.xls` dont les + dates sont des flottants ou d'un Access dont les dates sont des + chaînes : la règle ne doit jamais avoir à deviner. + """ + + def __init__(self, nom, lignes, masquee=False, source=None): + self.nom = nom + self.lignes = lignes + self.masquee = masquee + self.source = source + # (ligne, colonne) -> (conteneur, clé). Hors tableur, l'écriture + # passait par un SECOND parcours de l'arbre, qui ignorait la portée, + # le plancher et les colonnes intactes — au point de détruire un + # external ID que le plancher venait de protéger. Une ancre par + # cellule rend l'écriture solidaire de la marche à blanc. + self.ancres = {} + # L'arbre JSON ou XML dont ces ancres sont les branches. + self.arbre = None + # L'index 1-based de la colonne qui porte de la STRUCTURE, ou None. + # Le LECTEUR seul le sait : une grille (clé, valeur) en a une, un + # tableau de vrais champs n'en a aucune. Le déduire du FORMAT + # recopiait en clair le premier champ d'un tableau d'objets. + self.colonne_structure = None + # Vrai quand la ligne 1 de la grille est une ÉTIQUETTE fabriquée + # par le lecteur (« cle »/« valeur », « chemin »/« valeur ») : + # absente du fichier, elle n'est ni de la donnée ni un en-tête, et + # aucune ancre ne peut l'écrire. + self.ligne1_fabriquee = False + # Le porteur d'une racine SCALAIRE (`"x"`, `42` — du JSON valide). + # Un str ou un int ne se réécrit pas en place : sans cette case + # réinscriptible, l'ancre porte None et l'écriture déréférence None. + self.porteur = None + # La ligne 1-based qui NOMME les colonnes, ou None quand la feuille + # n'en a pas. Un tableur ne le dit pas : la ligne 1 est une + # PRÉSOMPTION, fausse sur un rapport dont A1 porte un titre et sur + # une feuille sans en-tête, où elle recopiait de la donnée en + # clair. `_preparer` la mesure ; les lecteurs qui FABRIQUENT leur + # ligne 1 — Access, JSON, XML — la connaissent d'avance. + self.ligne_champs = 1 + # L'empan des lignes d'en-tête, ligne de champs comprise : ce qui + # les surmonte sans être des données — un titre, une catégorie + # fusionnée — reste intact avec elle. + self.lignes_entete = {1} + # Ce que le FICHIER DÉCLARE, quand il le déclare : `headerRowCount` + # d'un tableau d'un onglet. La déclaration GAGNE sur la mesure — + # `_resynchroniser_tableaux` la lit déjà pour nommer les colonnes + # d'un tableau, et deux notions d'en-tête qui se contredisent + # feraient renommer un tableau depuis une ligne qu'on vient + # d'anonymiser. + self.entete_declaree = None + # Les premières lignes telles que le lecteur les a LUES, avant + # coercition. Un csv rend tout en chaînes ; les coercer donne au + # signal de type la matière dont il a besoin, mais une ligne + # d'en-tête doit garder ses chaînes — « 2024 » est un libellé, pas + # un nombre. `mesurer_entetes` les rend à l'empan retenu. + self.lignes_brutes = None + + @property + def etiquettes(self): + """Les libellés de colonne, pris sur la ligne de CHAMPS. + + Vide quand la feuille n'a pas d'en-tête : sans libellé, le + plancher ne peut pas reconnaître un identifiant et la question des + colonnes intactes répond par l'index. C'est le prix juste — les + prendre sur une ligne de données faisait planchéier au hasard. + """ + rang = self.ligne_champs + if rang is None or rang > len(self.lignes): + return [] + return list(self.lignes[rang - 1]) + + +def corps(feuille): + """Les lignes de DONNÉES : toutes celles qui ne sont pas de l'en-tête. + + Un seul endroit le dit, pour les deux graveurs qui écrivaient + `lignes[1:]` : une seconde ligne d'en-tête y devenait un + enregistrement, et sur une feuille SANS en-tête la première ligne de + données devenait les noms de clé — en clair, et perdue comme donnée. + + L'exclusion se fait par APPARTENANCE à l'empan, non par position sous + lui. Une ligne de données au-dessus de la ligne de champs en est + exclue exprès — la mesure la reconnaît, la portée l'anonymise — et + partir de la dernière ligne d'en-tête la faisait DISPARAÎTRE d'une + conversion json ou xml, comptée comme remplacée puis absente du + fichier. Un empan corrigé par l'opérateur n'est d'ailleurs pas tenu + d'être contigu. + """ + empan = feuille.lignes_entete or set() + return [ + ligne + for rang, ligne in enumerate(feuille.lignes, start=1) + if rang not in empan + ] + + +def _etiquettes_par_colonne(feuilles): + """{(feuille, colonne 1-based): étiquette}, lu AVANT toute écriture. + + Avant, parce que répondre « oui » à l'anonymisation de la ligne + d'en-tête remplacerait l'étiquette que la question des colonnes + intactes cherche ensuite. + """ + table = {} + for feuille in feuilles: + for index, etiquette in enumerate(feuille.etiquettes, start=1): + if isinstance(etiquette, str) and etiquette.strip(): + table[(feuille.nom, index)] = etiquette.strip() + return table + + +# ---------------------------------------------------------------------- +# La ligne d'en-tête : mesurée, non présumée +# ---------------------------------------------------------------------- +# Au-delà, on ne cherche plus : un tableau dont la ligne de champs vient +# plus bas qu'ici n'est pas un tableau mais un rapport mis en page, et +# l'opérateur corrige mieux que n'importe quelle mesure. +LIGNES_SONDEES = 10 + +# Jusqu'où l'écran MONTRE des lignes. La portée de l'opérateur n'est pas +# bornée par celle de la mesure : un rapport dont la ligne de champs vient +# en douzième position se corrige à la main, et le commentaire ci-dessus +# le promettait sans que rien ne l'offre — l'écran s'arrêtait à la +# dixième, donc la douzième était inatteignable. +# +# Les lignes au-delà de `LIGNES_SONDEES` arrivent SANS mesure : la +# calculer coûte un balayage de la feuille par ligne, et trente balayages +# sur un classeur de deux millions de cellules se voient. Une colonne de +# mesures vide y dit la vérité — la mesure n'est pas allée jusque-là. +LIGNES_MONTREES = 30 + +# Une ligne qui RESSEMBLE à ses données en est. Le seuil sépare deux +# nuages mesurés sur les huit tables d'une base réelle, prises une fois +# avec leur ligne d'en-tête et une fois sans : les en-têtes vont de 0,00 à +# 0,36, les lignes de données de 0,50 à 1,00. Le seuil penche vers le bas +# de l'écart, parce que les deux erreurs ne coûtent pas la même chose — +# manquer un en-tête l'anonymise et abîme la lecture de la copie, en +# inventer un le recopie en clair et fait SORTIR de la donnée. +ACCORD_DE_DONNEE = 0.40 + +# Ce qu'une ligne de champs doit tenir par ailleurs. Le remplissage écarte +# le titre seul en A1 ; la distinction écarte la ligne de catégorie, qui +# répète un même mot sur plusieurs colonnes. +REMPLISSAGE_MINIMAL = 0.5 +DISTINCTION_MINIMALE = 0.99 + +# L'un OU l'autre suffit, et l'un des deux est REQUIS : ce sont les seules +# preuves POSITIVES qu'une ligne nomme ses colonnes. `accord` bas dit +# seulement « elle ne ressemble pas à ses données », ce qui n'est pas la +# même chose — s'en contenter fait prendre une première ligne de +# données pour un en-tête sur une table tout-texte, donc la recopie en +# clair. +# +# Le contraste est franc sur une table numérique et MUET sur une table +# tout-texte ; l'appartenance à la colonne y répond, mais seulement là où +# la colonne a un VOCABULAIRE. Aucune des deux ne parle : pas d'en-tête. +# +# Le seuil du contraste est bas parce que toute ligne de DONNÉES mesurée +# vaut zéro — une ligne au-dessus d'une colonne numérique y est numérique +# elle aussi. Il n'est pas nul pour qu'une seule valeur texte égarée dans +# une colonne de nombres ne suffise pas : il en faut une part. +CONTRASTE_MINIMAL = 0.15 +HORS_COLONNE_MINIMAL = 0.9 + + +# Les lecteurs qui FABRIQUENT leur ligne 1 : elle porte des noms de champ +# ou des chemins de clé absents du fichier, donc l'empan est connu d'avance +# et rien n'est à mesurer. +FORMATS_ENTETE_FABRIQUEE = ("access", "json", "xml") + + +def mesurer_entetes(feuilles, format_lu, corrections=None, memoire=None): + """Poser, par feuille, l'empan d'en-tête et la ligne de champs. + + Cinq sources, dans cet ordre de priorité : + + 1. La CORRECTION de l'opérateur pour ce passage-ci, qui ne se + remesure jamais. + 2. Ce que la TABLE se rappelle : une correction faite sur un fichier + du même lot. C'est une réponse d'opérateur elle aussi, donc elle + passe avant ce que le fichier déclare. + 3. Ce que le FICHIER déclare — `headerRowCount` d'un tableau. + 4. Ce que le LECTEUR sait : Access, JSON et XML fabriquent leur + ligne 1, gardent le défaut et ne mesurent rien. + 5. La mesure, pour un tableur sans tableau déclaré et pour un csv. + + La ligne de CHAMPS est la plus BASSE de l'empan : c'est la convention + de `_resynchroniser_tableaux`, qui nomme les colonnes d'un tableau + depuis la dernière ligne de `headerRowCount`. L'adopter plutôt que + d'en inventer une seconde évite deux notions qui se contredisent. + """ + corrections = corrections or {} + memoire = memoire or {} + for feuille in feuilles: + demandee = corrections.get(feuille.nom) + if demandee is None: + demandee = memoire.get(feuille.nom) + if demandee is not None: + empan = {int(n) for n in demandee if int(n) >= 1} + elif feuille.entete_declaree: + empan = _empan_etendu(feuille.lignes, set(feuille.entete_declaree)) + elif format_lu in FORMATS_ENTETE_FABRIQUEE or feuille.ligne1_fabriquee: + empan = {1} + else: + empan, _champs = lignes_entete(feuille.lignes) + feuille.lignes_entete = empan + feuille.ligne_champs = max(empan) if empan else None + _rendre_les_brutes(feuille) + + +def _empan_etendu(lignes, empan): + """L'empan, remonté sur ce qui le surmonte sans être des données. + + La même remontée que la mesure, appliquée à l'empan DÉCLARÉ. Un + tableau OOXML déclare `headerRowCount`, donc SA ligne de champs, mais + ignore la mise en page posée au-dessus de lui : un titre de rapport en + A1 restait hors de l'empan, et les statistiques de colonne le + comptaient comme une valeur. Une colonne de relation cessait alors + d'avoir la FORME d'une relation, le plancher lâchait, et les + identifiants étaient permutés dans la copie — toutes ses relations + pointant ailleurs, sans un mot. + + À la différence de la mesure, l'empan n'a pas à ATTEINDRE la ligne 1 : + le tableau déclare sa propre ligne de champs, donc une donnée + au-dessus ne remet pas ce fait en cause. Elle borne seulement la + remontée, et reste en portée. + """ + if not empan: + return empan + etendu = set(empan) + haut = min(etendu) - 1 + while haut >= 1 and not _est_de_la_donnee(lignes, haut): + etendu.add(haut) + haut -= 1 + return etendu + + +def _rendre_les_brutes(feuille): + """Rendre à l'empan ses valeurs telles que le lecteur les a LUES. + + Un csv arrive tout en chaînes et se fait coercer pour que le signal de + type ait de la matière ; une ligne d'en-tête, elle, doit garder ses + chaînes — « 2024 » y est un libellé de colonne, pas un nombre. Sans + ce retour, l'étiquette d'une colonne annuelle devenait un entier, et + la réponse « 2024 » à la question des colonnes ne portait plus. + """ + brutes = feuille.lignes_brutes + if not brutes: + return + for numero in feuille.lignes_entete: + if 1 <= numero <= len(brutes) and numero <= len(feuille.lignes): + feuille.lignes[numero - 1] = list(brutes[numero - 1]) + + +# Ce qu'une colonne montre d'elle-même. Trois suffisent à reconnaître un +# champ ; borner la longueur garde la ligne lisible et la charge utile +# petite. +EXEMPLES_PAR_COLONNE = 3 +EXEMPLE_LONGUEUR = 40 + +# Au-delà, les exemples ne sont plus peuplés. `colonnes` elle-même n'est +# JAMAIS tronquée : les bornes, les formes et le plancher en dérivent +# colonne par colonne, et une liste coupée déplancherait en silence. +EXEMPLES_COLONNES_MAX = 500 + + +def valeur_d_exemple(valeur): + """Une valeur montrable : toujours une `str`, toujours bornée. + + Écarte les octets — `valeur_hors_tableur` tomberait sur son `str()` + final et rendrait le `repr` d'un `bytes`, donc du binaire en clair — + et les flottants non finis, que `allow_nan=False` refuserait à la + sérialisation, c'est-à-dire APRÈS tout le travail du moteur, l'écran + n'affichant alors qu'une trace. + + Un seul type dans le champ : ni le menu ni l'écran n'ont à brancher. + Cette valeur ne passe JAMAIS par `t()`, qui chercherait une clé de + traduction dans une donnée du client. + """ + if isinstance(valeur, (bytes, bytearray)): + return "<%d octets>" % len(valeur) + if isinstance(valeur, float) and not math.isfinite(valeur): + return "" + # `or ""` écraserait le zéro et le faux, qui sont de VRAIES valeurs : + # une colonne de montants nuls montrait des exemples vides. + rendu = valeur_hors_tableur(valeur) + texte = "" if rendu is None else str(rendu) + if len(texte) > EXEMPLE_LONGUEUR: + return texte[: EXEMPLE_LONGUEUR - 1] + "…" + return texte + + +def _forme_de_valeur(valeur): + """La signature de forme d'une valeur : chiffres en 9, lettres en a. + + Les répétitions sont écrasées, si bien que « 99999 » et « 999999 » + sont une seule forme. Une ligne de données partage la forme de sa + colonne — « ZK204817 » au-dessus de « ZK204818 » — et un nom de champ + non. C'est ce qui tranche là où le type ne dit rien, les deux étant du + texte. + """ + if valeur is None: + return "" + texte = re.sub(r"[0-9]", "9", str(valeur).strip()) + texte = re.sub(r"[^\W\d_]", "a", texte) + return re.sub(r"(.)\1+", r"\1+", texte) + + +def _accord_de_forme(lignes, rang): + """Part des colonnes où la ligne partage la forme dominante du corps. + + Haut : la ligne ressemble à ses données, donc c'en est. Bas : elle s'en + distingue, donc elle les nomme. Rend None quand il n'y a pas de corps + sous la ligne — rien à comparer n'est pas un verdict. + """ + largeur = max((len(l) for l in lignes), default=0) + if rang > len(lignes) or not largeur: + return None + ligne = lignes[rang - 1] + corps = lignes[rang:] + if not corps: + return None + accords = compares = 0 + for index in range(largeur): + valeurs = [ + l[index] + for l in corps + if index < len(l) and classer(l[index]) != "vide" + ] + cellule = ligne[index] if index < len(ligne) else None + if not valeurs or classer(cellule) == "vide": + continue + compares += 1 + formes = collections.Counter(_forme_de_valeur(v) for v in valeurs) + if _forme_de_valeur(cellule) == _forme_dominante(formes): + accords += 1 + return accords / compares if compares else 0.0 + + +def _forme_dominante(formes): + """La forme la plus fréquente, la plus petite en cas d'égalité. + + `most_common` tranche une égalité par l'ordre d'INSERTION : deux + agrégations du même corps dans un ordre différent donnaient alors deux + dominantes, donc deux verdicts. Le départage explicite est arbitraire + mais stable, ce qui est tout ce qu'on lui demande. + """ + if not formes: + return None + return max(formes.items(), key=lambda paire: (paire[1], paire[0]))[0] + + +def _signaux_entete(lignes, rang): + """Quatre mesures de la ligne `rang` comme ligne de champs. + + `contraste` : elle est du texte au-dessus d'une colonne qui n'en porte + pas. `hors_colonne` : sa valeur ne figure pas parmi celles de sa + colonne — zéro collision mesurée sur 55 colonnes tout-texte réelles. + `rempli` : elle couvre la largeur utile. `distinct` : ses libellés ne + se répètent pas. + """ + largeur = max((len(l) for l in lignes), default=0) + if rang > len(lignes) or not largeur: + return None + ligne = lignes[rang - 1] + corps = lignes[rang:] + if not corps: + return None + contraste = hors_colonne = compares = colonnes_a_vocabulaire = 0 + for index in range(largeur): + valeurs = [l[index] for l in corps if index < len(l)] + familles = {classer(v) for v in valeurs} - {"vide"} + cellule = ligne[index] if index < len(ligne) else None + if not familles or classer(cellule) == "vide": + continue + compares += 1 + if classer(cellule) == "texte" and "texte" not in familles: + contraste += 1 + non_vides = [v for v in valeurs if classer(v) != "vide"] + vues = {str(v).strip().lower() for v in non_vides} + # Ce signal n'a de pouvoir que sur une colonne à VOCABULAIRE, où + # des valeurs se répètent. Dans une colonne tout-distincte — des + # noms, des courriels, des numéros de pièce — une ligne de DONNÉES + # est absente du reste de sa colonne exactement autant qu'un + # libellé l'est : rapporté à la largeur, le signal y vaut 1,0 pour + # n'importe quelle ligne. Les compter laisse `accord` seul sur une + # table tout-texte, où une première ligne de données ponctuée + # autrement que son corps passe alors pour un en-tête : recopiée + # en clair dans la copie. + if len(vues) < len(non_vides): + colonnes_a_vocabulaire += 1 + if str(cellule).strip().lower() not in vues: + hors_colonne += 1 + pleines = sum( + 1 + for index in range(largeur) + if index < len(ligne) and classer(ligne[index]) != "vide" + ) + etiquettes = [ + str(ligne[index]).strip().lower() + for index in range(min(largeur, len(ligne))) + if classer(ligne[index]) != "vide" + ] + return { + "contraste": contraste / compares if compares else 0.0, + # Son propre dénominateur : les colonnes où il porte quelque + # chose. Aucune n'en a, il vaut 0,0 — c'est-à-dire « ce signal ne + # dit rien ici », et non « la ligne est de la donnée ». + "hors_colonne": ( + hors_colonne / colonnes_a_vocabulaire + if colonnes_a_vocabulaire + else 0.0 + ), + "rempli": pleines / largeur, + "distinct": ( + len(set(etiquettes)) / len(etiquettes) if etiquettes else 0.0 + ), + "compares": compares, + } + + +def _signaux_par_rang(lignes, jusqu_a): + """{rang: (signaux, accord)} pour les rangs 1..`jusqu_a`, en UNE passe. + + `corps(rang) = {ligne rang+1} ∪ corps(rang+1)` : en DESCENDANT les + rangs, chaque pas ajoute une ligne à l'état par colonne au lieu de + rebalayer le corps. Rang par rang, les dix rangs sondés coûtaient + vingt balayages de la feuille — quatre cinquièmes du temps du rapport + sur un export de cinq cent mille cellules. + + Les valeurs sont celles que rendent `_signaux_entete` et + `_accord_de_forme` : mêmes définitions, et la dominante de forme y est + départagée explicitement, sans quoi deux ordres d'agrégation du même + corps donneraient deux verdicts. + """ + largeur = max((len(l) for l in lignes), default=0) + jusqu_a = min(jusqu_a, len(lignes)) + if jusqu_a < 1: + return {} + if not largeur: + # Une feuille de lignes vides : chaque rang existe et ne mesure + # rien. Rendre la même forme qu'ailleurs épargne un cas + # particulier à chaque appelant. + return {rang: (None, None) for rang in range(1, jusqu_a + 1)} + familles = [set() for _ in range(largeur)] + vues = [set() for _ in range(largeur)] + pleines = [0] * largeur + formes = [collections.Counter() for _ in range(largeur)] + + def ajouter(ligne): + for index in range(min(largeur, len(ligne))): + valeur = ligne[index] + famille = classer(valeur) + if famille == "vide": + continue + familles[index].add(famille) + vues[index].add(str(valeur).strip().lower()) + pleines[index] += 1 + formes[index][_forme_de_valeur(valeur)] += 1 + + for ligne in lignes[jusqu_a:]: + ajouter(ligne) + rendu = {} + for rang in range(jusqu_a, 0, -1): + if rang < jusqu_a: + # La ligne `rang + 1` en 1-based : celle que le corps gagne. + ajouter(lignes[rang]) + if rang >= len(lignes): + # Pas de corps sous la ligne : rien à comparer n'est pas un + # verdict, et les deux fonctions rang par rang rendent None. + rendu[rang] = (None, None) + continue + rendu[rang] = _mesurer_depuis_l_etat( + lignes[rang - 1], largeur, familles, vues, pleines, formes + ) + return rendu + + +def _mesurer_depuis_l_etat(ligne, largeur, familles, vues, pleines, formes): + """(signaux, accord) d'une ligne, contre l'état du corps sous elle.""" + contraste = hors_colonne = compares = a_vocabulaire = accords = 0 + for index in range(largeur): + cellule = ligne[index] if index < len(ligne) else None + if not familles[index] or classer(cellule) == "vide": + continue + compares += 1 + if classer(cellule) == "texte" and "texte" not in familles[index]: + contraste += 1 + if len(vues[index]) < pleines[index]: + a_vocabulaire += 1 + if str(cellule).strip().lower() not in vues[index]: + hors_colonne += 1 + if _forme_de_valeur(cellule) == _forme_dominante(formes[index]): + accords += 1 + remplies = sum( + 1 + for index in range(largeur) + if index < len(ligne) and classer(ligne[index]) != "vide" + ) + etiquettes = [ + str(ligne[index]).strip().lower() + for index in range(min(largeur, len(ligne))) + if classer(ligne[index]) != "vide" + ] + signaux = { + "contraste": contraste / compares if compares else 0.0, + "hors_colonne": ( + hors_colonne / a_vocabulaire if a_vocabulaire else 0.0 + ), + "rempli": remplies / largeur, + "distinct": ( + len(set(etiquettes)) / len(etiquettes) if etiquettes else 0.0 + ), + "compares": compares, + } + return signaux, (accords / compares if compares else 0.0) + + +def _est_une_ligne_de_champs(signaux, accord): + """Le verdict, seuils nommés à l'appui. + + Une limite à connaître : l'accord de forme est AVEUGLE quand le nom + d'un champ et ses valeurs partagent une classe de caractères — « nom » + au-dessus de mots, sur une grille étroite et tout-alphabétique. Rien + de structurel ne les sépare alors ; seul un vocabulaire le ferait, et + une liste de noms de champs connus est une classe OUVERTE. Le verdict + y est donc « pas d'en-tête », ce qui anonymise la ligne — le côté sur + lequel pencher — et l'opérateur corrige. + + Une classe de LONGUEUR ne lève pas l'aveuglement : « ZK204817 » et + « MPQ204818 » doivent s'accorder alors que « etiquette » et + « aboulie » doivent se distinguer, et aucune frontière ne fait les + deux — mesuré sur cinq jeux de seuils. + """ + if signaux is None or accord is None: + return False + return ( + signaux["rempli"] >= REMPLISSAGE_MINIMAL + and signaux["distinct"] >= DISTINCTION_MINIMALE + and accord <= ACCORD_DE_DONNEE + and ( + signaux["contraste"] >= CONTRASTE_MINIMAL + or signaux["hors_colonne"] >= HORS_COLONNE_MINIMAL + ) + ) + + +def _est_de_la_donnee(lignes, rang, mesures=None): + """Cette ligne, au-dessus de la ligne de champs, est-elle une DONNÉE ? + + `mesures` est le lot rendu par `_signaux_par_rang`, quand l'appelant + l'a déjà : le recalculer rang par rang rebalaie la feuille deux fois + par ligne remontée. + + Deux conditions, et les deux sont nécessaires : elle ressemble à ses + données par la forme, ET elle en remplit la largeur. La forme seule ne + suffit pas — un titre seul en A1 partage la forme de la colonne de + noms qu'il surmonte, et sans la seconde l'extension s'arrête dessus et + le met en portée. Le remplissage seul ne suffit pas non plus : une + ligne de catégorie couvre la largeur sans être des données. + + L'erreur va du bon côté quand elle se produit : une ligne prise pour + des données est ANONYMISÉE, non recopiée. + """ + if mesures is None: + signaux = _signaux_entete(lignes, rang) + accord = _accord_de_forme(lignes, rang) + else: + signaux, accord = mesures.get(rang, (None, None)) + if accord is None or accord < 0.5: + return False + return signaux is not None and signaux["rempli"] >= REMPLISSAGE_MINIMAL + + +def lignes_entete(lignes): + """(empan des lignes d'en-tête, ligne de champs) — 1-based. + + La ligne de CHAMPS est celle que la mesure retient : c'est elle qui + nomme les colonnes, donc celle dont `etiquettes` sort. L'EMPAN y ajoute + les lignes du dessus qui ne ressemblent pas à des données — un titre de + rapport, une ligne de catégorie fusionnée : les anonymiser n'apporte + rien et rend la copie illisible. + + Rend `(set(), None)` quand aucune ligne ne mesure comme une ligne de + champs. C'est un verdict, pas un échec : une feuille sans en-tête + existe, et sa ligne 1 est de la DONNÉE — la présumer d'en-tête la + recopiait en clair. + + L'empan doit ATTEINDRE la ligne 1. Une remontée bornée par une ligne + qui mesure comme de la donnée place le candidat AU MILIEU d'elle : un + en-tête ne se trouve pas sous des enregistrements. Sur une feuille + courte, le corps sous le rang sondé fond jusqu'à trois lignes et la + forme dominante y bascule dès qu'un compteur change de nombre de + chiffres — la ligne se distingue alors de ce qui reste sans nommer + quoi que ce soit, et sortait en clair au milieu des données. Le prix + est un export dont la ligne 1 est un enregistrement égaré : il n'a + plus d'en-tête reconnu, donc son en-tête est anonymisé, et l'écran le + corrige — le côté qui ne fait pas sortir de donnée. + """ + if not lignes: + return set(), None + mesures = _signaux_par_rang(lignes, LIGNES_SONDEES) + for rang in sorted(mesures): + signaux, accord = mesures[rang] + if not _est_une_ligne_de_champs(signaux, accord): + continue + empan = {rang} + haut = rang - 1 + while haut >= 1 and not _est_de_la_donnee(lignes, haut, mesures): + empan.add(haut) + haut -= 1 + if haut >= 1: + continue + return empan, rang + return set(), None + + +def _stats_colonnes(feuille): + """Par colonne : étiquette, type dominant, remplies, distinctes, bornes. + + Le coût est nul — la passe visite déjà chaque cellule — et c'est elle + qui produit les BORNES dont la règle du nombre a besoin, en même temps + que les étiquettes que teste le plancher. Sans ce bloc, les questions + sur les colonnes et sur la ligne d'en-tête sont impossibles à + répondre : il faudrait ouvrir le fichier dans Excel d'abord, ce que + cette entrée existe pour supprimer. + """ + largeur = max((len(l) for l in feuille.lignes), default=0) + colonnes = [] + etiquettes = feuille.etiquettes + for index in range(largeur): + familles = {} + distinctes = set() + remplies = 0 + forme = True + forme_rel = True + mini = maxi = None + entiere = True + exemples = [] + for numero, ligne in enumerate(feuille.lignes, start=1): + # L'EMPAN, jamais le littéral « 1 » : `forme_identifiant` et + # `forme_relation` se court-circuitent sur un seul faux, si + # bien qu'une ligne d'en-tête laissée dans la mesure fait + # lâcher le plancher sur `partner_id`, `key`, `model` et + # `state` — exactement les colonnes pour lesquelles il existe. + if numero in (feuille.lignes_entete or ()): + continue + valeur = ligne[index] if index < len(ligne) else None + famille = classer(valeur) + if famille == "vide": + continue + remplies += 1 + forme = forme and valeur_forme_identifiant(valeur) + forme_rel = forme_rel and noyau.valeur_forme_relation(valeur) + familles[famille] = familles.get(famille, 0) + 1 + if len(distinctes) < 10000: + avant = len(distinctes) + try: + distinctes.add(valeur) + except TypeError: + distinctes.add(repr(valeur)) + # Une valeur INÉDITE et rien qu'elle : trois exemples + # identiques ne montrent rien de la colonne. La valeur est + # déjà en main et la passe est déjà payée. + if ( + len(distinctes) > avant + and len(exemples) < EXEMPLES_PAR_COLONNE + and index < EXEMPLES_COLONNES_MAX + ): + montrable = valeur_d_exemple(valeur) + if montrable: + exemples.append(montrable) + if ( + famille == "nombre" + and not isinstance(valeur, bool) + and math.isfinite(valeur) + ): + # NaN et l'infini ne bornent rien : `min` et `max` les + # ignorent selon l'ORDRE des arguments, ce qui n'est pas + # une garantie sur laquelle asseoir un intervalle de + # tirage. + mini = valeur if mini is None else min(mini, valeur) + maxi = valeur if maxi is None else max(maxi, valeur) + # Le verdict porte sur TOUTE la colonne : une seule + # décimale quelque part la rend décimale. + entiere = entiere and float(valeur).is_integer() + etiquette = etiquettes[index] if index < len(etiquettes) else None + dominant = ( + max(familles.items(), key=lambda kv: kv[1])[0] + if familles + else "vide" + ) + colonnes.append( + { + "index": index + 1, + "etiquette": ( + str(etiquette).strip() + if isinstance(etiquette, str) + else None + ), + "type": dominant, + "remplies": remplies, + "distinctes": len(distinctes), + # CE qui distingue deux colonnes sans libellé : mesuré, ni + # le type, ni le compte, ni les bornes n'y suffisent. + "exemples": exemples, + "min": mini, + "max": maxi, + "entiere": entiere and mini is not None, + "forme_identifiant": forme, + "forme_relation": forme_rel, + # Une SÉLECTION est un ensemble fermé et petit. Sans cette + # borne, une colonne de provinces ou de créneaux nommés par + # des personnes passait pour une sélection sur le seul fait + # d'être en minuscules. + "selection": ( + len(distinctes) <= noyau.SELECTION_MAX_DISTINCTES + ), + "plancher": colonne_plancher( + etiquette, + forme, + forme_rel, + len(distinctes) <= noyau.SELECTION_MAX_DISTINCTES, + ), + } + ) + return colonnes + + +def _formes_par_colonne(rapport, cle="forme_identifiant"): + """{(feuille, colonne): son contenu a-t-il cette forme mesurée}.""" + return { + (feuille["nom"], colonne["index"]): bool(colonne.get(cle)) + for feuille in rapport.get("feuilles", []) + for colonne in feuille.get("colonnes", []) + } + + +def _bornes_par_colonne(rapport): + """{(feuille, colonne): (min, max, entiere)} depuis le rapport. + + Le troisième terme dit si la colonne ne porte QUE des entiers. Il ne + se déduit pas du type Python d'une valeur : `.xls` ne stocke que des + doubles, et son lecteur rend 100 en `100.0`. + """ + bornes = {} + for feuille in rapport.get("feuilles", []): + for colonne in feuille.get("colonnes", []): + if colonne.get("min") is not None: + bornes[(feuille["nom"], colonne["index"])] = ( + colonne["min"], + colonne["max"], + colonne.get("entiere"), + ) + return bornes + + +# ---------------------------------------------------------------------- +# Les lecteurs +# ---------------------------------------------------------------------- +# L'horodatage que porte toute copie. Une date fixe et lointaine, non +# l'époque Unix : `dcterms:created` à 1970 se lit comme une donnée abîmée, +# là où une date ronde se lit comme une date posée exprès. +EPOQUE_FIGEE = datetime.datetime(2000, 1, 1) + + +def _lire_xlsx(chemin, garder_vba=False): + """Le classeur, et ses feuilles de calcul. + + `wb.worksheets` et JAMAIS `wb.sheetnames` : ce dernier mêle les + feuilles graphiques, et `wb[nom]` rend alors un `Chartsheet`, qui n'a + ni `max_row`, ni `max_column`, ni `iter_rows`. + """ + from openpyxl import load_workbook + + try: + classeur = load_workbook( + chemin, + data_only=False, + keep_links=False, + keep_vba=bool(garder_vba), + ) + except ErreurMoteur: + raise + except Exception as exc: + # La bibliothèque ne lit pas tout ce qu'Excel écrit : une feuille + # graphique DÉPOURVUE de graphique fait lever `AttributeError` + # dans son lecteur de relations, en 3.1.2. Ce refus nomme la + # cause ; sans lui, un fichier qui s'ouvre dans Excel ressort en + # « format non reconnu » suivi d'un message Python. + raise ErreurMoteur( + "lecture_impossible", f"{type(exc).__name__}: {exc}" + ) + feuilles = [] + for onglet in classeur.worksheets: + lignes = [ + [cellule.value for cellule in ligne] + for ligne in onglet.iter_rows() + ] + feuille = Feuille( + onglet.title, + lignes, + masquee=onglet.sheet_state != "visible", + source=onglet, + ) + feuille.entete_declaree = _entete_declaree(onglet) + feuilles.append(feuille) + return classeur, feuilles + + +def _entete_declaree(onglet): + """L'empan d'en-tête que les TABLEAUX de l'onglet déclarent, ou None. + + Un tableau OOXML porte `headerRowCount` et la première ligne de son + `ref` : l'empan s'en déduit sans rien mesurer. C'est la même donnée que + `_resynchroniser_tableaux` lit pour nommer les colonnes, et l'adopter + ici évite deux notions d'en-tête qui se contredisent — un tableau + renommé depuis une ligne qu'on vient d'anonymiser. + + Plusieurs tableaux sur un onglet : l'empan est leur RÉUNION, chacun + gardant sa propre ligne d'en-tête intacte. + """ + empan = set() + for tableau in (getattr(onglet, "tables", {}) or {}).values(): + ref = getattr(tableau, "ref", "") or "" + if ":" not in ref and not ref: + continue + try: + premiere = onglet[ref.split(":")[0]].row + except (ValueError, KeyError, TypeError): + continue + hauteur = getattr(tableau, "headerRowCount", 1) + try: + hauteur = int(hauteur if hauteur is not None else 1) + except (TypeError, ValueError): + hauteur = 1 + if hauteur < 1: + # Un tableau déclaré SANS ligne d'en-tête : sa première ligne + # est de la donnée, et rien n'est à garder pour lui. + continue + empan.update(range(premiere, premiere + hauteur)) + return empan or None + + +def _lire_xls(chemin): + import xlrd + + # `logfile` vaut sys.stdout par défaut, et xlrd y écrit dès qu'un + # classeur n'a pas de CODEPAGE : la ligne se mêlait à l'unique objet + # JSON de stdout, et l'appelant refusait un fichier lisible sans un mot + # de diagnostic. + try: + classeur = xlrd.open_workbook( + chemin, formatting_info=False, logfile=sys.stderr + ) + except Exception as exc: + # La bibliothèque échoue sur un fichier MALFORMÉ, et pas seulement + # sur un format qu'elle ignore : un flux de classeur abîmé, un nom + # défini dont la formule ne s'évalue pas. Sans ce refus, la sortie + # annonçait « format non reconnu » — faux, le format est reconnu — + # suivi d'un message Python. + raise ErreurMoteur( + "lecture_impossible", f"{type(exc).__name__}: {exc}" + ) + feuilles = [] + for onglet in classeur.sheets(): + lignes = [] + for numero in range(onglet.nrows): + lignes.append( + [ + normaliser_xls( + onglet.cell_type(numero, colonne), + onglet.cell_value(numero, colonne), + classeur.datemode, + ) + for colonne in range(onglet.ncols) + ] + ) + feuilles.append( + Feuille(onglet.name, lignes, masquee=onglet.visibility != 0) + ) + return feuilles + + +# Le binaire de mdbtools qui liste les requêtes enregistrées d'une base +# Access. `access-parser` n'en rend aucune : il lit les TABLES, et le +# catalogue où vivent les requêtes est écarté exprès — il porte aussi les +# noms d'objets système et, pour une table liée, le chemin source. +# +# Le nom est répété dans `transform_setup`, qui répond à « cette machine +# sait-elle les lire ». Ce module tourne sous l'interpréteur du MOTEUR et +# celui-là sous celui du CLI : les faire dépendre l'un de l'autre +# rendrait le moteur inimportable là où il tourne. +BINAIRE_REQUETES = "mdb-queries" + + +def requetes_access(chemin): + """Les noms des requêtes enregistrées, ou None si on ne sait pas lire. + + None et `[]` ne disent pas la même chose, et les confondre était le + défaut : `[]` veut dire « cette base n'en porte aucune », None veut + dire « personne ici ne sait le dire ». L'aperçu doit pouvoir annoncer + la seconde comme une IGNORANCE, non comme une absence. + + Une requête ne passe jamais dans la copie : le graveur ne connaît que + des feuilles, tirées des tables. C'est donc une PERTE à annoncer, du + même genre que les images d'un classeur — et une base dont la moitié + du travail vit dans ses requêtes se transmet amputée sans un mot. + """ + import shutil + import subprocess + + binaire = shutil.which(BINAIRE_REQUETES) + if not binaire: + return None + try: + rendu = subprocess.run( + [binaire, "-1", chemin], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return None + if rendu.returncode: + return None + return [l.strip() for l in rendu.stdout.splitlines() if l.strip()] + + +def _lire_access(chemin): + """Les tables d'un `.mdb`/`.accdb`, reconstruites ligne par ligne. + + Deux pièges d'`access-parser`, tous deux dans sa source : + + `_parse_catalog` ajoute `MSysObjects` AVANT le filtre des objets + système, sans condition. Itérer `db.catalog` livrerait donc le + catalogue d'objets d'Access — noms, propriétaires, et pour une table + liée le chemin source. On écarte tout `MSys*`. + + `parse_table` rend un dictionnaire de colonnes de LONGUEURS INÉGALES : + `_parse_row` sort tôt après avoir déjà alimenté les colonnes de + longueur fixe. Reconstruire par `zip(*valeurs)` tronquerait à la plus + courte et apparierait un nom avec les nombres d'une autre ligne — + pire qu'une erreur, parce que la sortie a l'air correcte. On aligne + donc sur la plus LONGUE, en comblant par `None`. + """ + from access_parser import AccessParser + + try: + base = AccessParser(chemin) + except Exception as exc: + raise ErreurMoteur( + "lecture_impossible", f"{type(exc).__name__}: {exc}" + ) + feuilles = [] + for nom in base.catalog: + if str(nom).startswith("MSys"): + continue + try: + colonnes = base.parse_table(nom) + except Exception as exc: # une table illisible n'arrête pas tout + progres(f"{nom}: {type(exc).__name__}") + continue + etiquettes = list(colonnes.keys()) + # Le TYPE déclaré de chaque colonne, pour normaliser avant que + # l'anonymiseur voie quoi que ce soit : `access-parser` rend une + # date et un montant en CHAÎNE, et la règle du texte les prendrait + # pour du texte du client. + types = {} + try: + for col in base.get_table(nom).columns.values(): + types[col.col_name_str] = col.type + except Exception as exc: # pragma: no cover - table hors norme + progres(f"{nom}: types indisponibles ({type(exc).__name__})") + hauteur = max((len(v) for v in colonnes.values()), default=0) + lignes = [etiquettes] + for index in range(hauteur): + lignes.append( + [ + normaliser_access( + ( + colonnes[cle][index] + if index < len(colonnes[cle]) + else None + ), + types.get(cle), + ) + for cle in etiquettes + ] + ) + feuilles.append(Feuille(str(nom), lignes)) + return feuilles + + +def _encodage_csv(chemin): + """(encodage, par quoi il a été décidé). + + Le BOM d'abord, `chardet` ensuite s'il est là, puis un décodage d'essai + du FICHIER ENTIER — pas d'un préfixe, qui couperait une séquence + multi-octets et ferait passer de l'UTF-8 valide pour du cp1252 — et + `cp1252` en dernier, qui ne lève jamais. Le rapport dit lequel a + répondu, pour qu'une supposition ne se lise pas comme une mesure. + """ + with open(chemin, "rb") as fh: + octets = fh.read() + for bom, nom in ( + (b"\xef\xbb\xbf", "utf-8-sig"), + (b"\xff\xfe\x00\x00", "utf-32"), + (b"\x00\x00\xfe\xff", "utf-32"), + (b"\xff\xfe", "utf-16"), + (b"\xfe\xff", "utf-16"), + ): + if octets.startswith(bom): + return nom, "bom" + try: + import chardet + + devine = chardet.detect(octets) + if devine and devine.get("encoding") and devine["confidence"] > 0.8: + return devine["encoding"], "chardet" + except Exception: + pass + try: + octets.decode("utf-8") + return "utf-8", "repli" + except UnicodeDecodeError: + return "cp1252", "repli" + + +def _delimiteur_csv(texte): + """(délimiteur, par quoi il a été décidé). + + `csv.Sniffer().sniff()` LÈVE sur un CSV à une seule colonne et sur un + fichier en dents de scie — exactement les deux formes que le rapport + promet de couvrir — et rend une LETTRE prise dans la donnée si on ne le + borne pas. Son verdict est donc borné, rattrapé, puis VALIDÉ : est + retenu le candidat qui donne le même nombre de champs sur les vingt + premières lignes non vides. + """ + candidats = [",", ";", "\t", "|"] + echantillon = texte[:65536] + try: + devine = csv.Sniffer().sniff(echantillon, delimiters=",;\t|") + if devine.delimiter in candidats: + candidats.insert( + 0, candidats.pop(candidats.index(devine.delimiter)) + ) + mesure = "sniffer" + else: + mesure = "repli" + except csv.Error: + mesure = "repli" + + lignes = [l for l in echantillon.splitlines() if l.strip()][:20] + for candidat in candidats: + largeurs = {len(l.split(candidat)) for l in lignes} + if len(largeurs) == 1 and largeurs != {1}: + return candidat, mesure if candidat == candidats[0] else "mesure" + return ",", "repli" + + +def _lire_csv(chemin): + encodage, source_enc = _encodage_csv(chemin) + with open(chemin, "r", encoding=encodage, errors="replace") as fh: + texte = fh.read() + delimiteur, source_del = _delimiteur_csv(texte) + # `csv.reader` ne rend que des chaînes : un champ numérique doit + # retrouver son type ici, sinon la colonne de montants est traitée + # comme du texte et chaque montant devient un mot. + # L'EN-TÊTE garde son type d'origine : la coercition le rendait en + # entier, donc en étiquette absente, et « 1 » ne désignait plus la + # colonne étiquetée « 1 » mais la première colonne. + lignes = [] + brutes = [] + for numero, ligne in enumerate( + csv.reader(io.StringIO(texte), delimiter=delimiteur), start=1 + ): + # TOUTES les lignes sont coercées, ligne 1 comprise : le signal de + # type compare une ligne aux VRAIS types de sa colonne, et lui + # laisser ses chaînes faisait passer toute ligne 1 pour un en-tête + # — donc en inventer un là où il n'y en a pas, ce qui recopie de + # la donnée en clair. `mesurer_entetes` rend ensuite leurs chaînes + # aux seules lignes de l'empan retenu, où « 2024 » est un libellé. + # La fenêtre des brutes couvre la portée de l'ÉCRAN, non celle + # de la mesure : une ligne d'en-tête désignée au-delà retrouvait + # ses valeurs coercées, et « 2024 » y redevenait un nombre. + if numero <= LIGNES_MONTREES: + brutes.append(list(ligne)) + lignes.append([coercer_texte(champ) for champ in ligne]) + nom = NOM_FEUILLE_NEUTRE + meta = { + "encodage": encodage, + "encodage_source": source_enc, + "delimiteur": delimiteur, + "delimiteur_source": source_del, + } + feuille = Feuille(nom, lignes) + feuille.lignes_brutes = brutes + return feuille, meta + + +# ---------------------------------------------------------------------- +# Les rapports +# ---------------------------------------------------------------------- +def _rapport_commun(chemin, format_lu): + return { + "chemin": chemin, + "format": format_lu, + "taille": os.path.getsize(chemin), + "divergence": format_divergent(chemin, format_lu), + "vivier": len(vivier_de_mots()), + "vivier_complet": _importable("randomwordfr"), + "lecture_seule": format_lu in noyau.FORMATS_LECTURE_SEULE, + "feuilles": [], + "comptes": {}, + "hors_cellules": {}, + "avertissements": [], + } + + +def _comptes_de(feuilles): + comptes = {} + for feuille in feuilles: + for numero, ligne in enumerate(feuille.lignes, start=1): + for valeur in ligne: + famille = classer(valeur) + if famille == "vide": + continue + comptes[famille] = comptes.get(famille, 0) + 1 + return comptes + + +def _feuilles_en_rapport(feuilles): + resume = [] + for feuille in feuilles: + colonnes = _stats_colonnes(feuille) + formules = sum( + 1 + for ligne in feuille.lignes + for valeur in ligne + if classer(valeur) == "formule" + ) + litteraux = sum( + 1 + for ligne in feuille.lignes + for valeur in ligne + if classer(valeur) == "formule" and '"' in str(valeur) + ) + empan = sorted(feuille.lignes_entete or ()) + resume.append( + { + "nom": feuille.nom, + # Une LISTE, non un set : `json.dump` refuse un set, et + # ce rapport traverse un sous-processus. + "lignes_entete": empan, + "ligne_champs": feuille.ligne_champs, + "entete_mesure": _mesure_de_la_ligne(feuille), + "lignes_sondees": _lignes_sondees(feuille), + "entete_declaree": bool(feuille.entete_declaree), + # COMBIEN de colonnes n'ont pas d'exemple, non un + # simple « oui ». Un booléen que personne ne lisait ne + # disait pas plus qu'une case vide, et une colonne sans + # exemple se lit comme une colonne vide — l'opérateur + # laisse alors intacte, ou non, une colonne qu'il n'a pas + # vue. + "exemples_manquants": max( + 0, + max((len(l) for l in feuille.lignes), default=0) + - EXEMPLES_COLONNES_MAX, + ), + "lignes": len(feuille.lignes), + "colonnes_n": max((len(l) for l in feuille.lignes), default=0), + "masquee": feuille.masquee, + "formules": formules, + "formules_litteral": litteraux, + "colonnes": colonnes, + } + ) + return resume + + +def _resynchroniser_le_rapport(rapport, feuilles): + """Remettre le rapport sur l'empan que l'opérateur a DÉSIGNÉ. + + `report` mesure AVANT qu'il réponde, et tout ce qui suit travaille sur + sa réponse. Les deux se lisaient l'un pour l'autre : la portée + reconnaît une colonne par l'étiquette de la ligne DÉSIGNÉE, tandis que + l'aperçu la reconnaissait par celle de la ligne MESURÉE. Il annonçait + donc épargnée une colonne que la passe anonymisait, et le plancher + lâchait sur une colonne de relation dont l'étiquette avait changé. + + Les bornes, les formes et le plancher dérivent tous du rapport : les + refaire ici les met d'accord d'un coup. + + Seul ce qui DÉPEND de l'empan est refait, et seulement sur une feuille + dont l'empan a changé. Les lignes sondées et les mesures sont pour + l'écran, qui a déjà répondu, et les refaire coûte dix balayages de la + feuille. + """ + par_nom = {f.nom: f for f in feuilles} + for resume in rapport.get("feuilles") or []: + feuille = par_nom.get(resume.get("nom")) + if feuille is None: + continue + empan = sorted(feuille.lignes_entete or ()) + if empan == resume.get("lignes_entete"): + continue + resume["lignes_entete"] = empan + resume["ligne_champs"] = feuille.ligne_champs + resume["colonnes"] = _stats_colonnes(feuille) + + +def _lignes_sondees(feuille): + """Les premières lignes, montrables, avec ce que la mesure en dit. + + Sans elles, un écran ne peut pas faire juger QUELLE ligne nomme les + colonnes : le rapport dit son verdict mais pas la matière sur laquelle + il porte, et contredire un verdict qu'on ne voit pas est un pari. + + Les mesures accompagnent chaque ligne, pas seulement celle retenue : + c'est ainsi qu'on voit pourquoi la voisine a été écartée. + """ + rendu = [] + mesures = _signaux_par_rang(feuille.lignes, LIGNES_SONDEES) + for rang in range(1, min(LIGNES_MONTREES, len(feuille.lignes)) + 1): + ligne = feuille.lignes[rang - 1] + # Filtrer AVANT de borner. La tranche posée d'abord ne montrait + # RIEN d'une ligne dont les trois premières cellules sont vides — + # un bloc d'en-tête décalé de quelques colonnes donnait six lignes + # d'aperçu toutes vides, et l'écran existe pour faire juger + # LAQUELLE nomme les colonnes. On s'arrête aux trois premières + # valeurs pleines plutôt que de parcourir une ligne large. + apercu = [] + for valeur in ligne: + if classer(valeur) == "vide": + continue + apercu.append(valeur_d_exemple(valeur)) + if len(apercu) >= EXEMPLES_PAR_COLONNE: + break + signaux, accord = mesures.get(rang, (None, None)) + mesure = None + if signaux is not None and accord is not None: + mesure = { + "contraste": round(signaux["contraste"], 2), + "hors_colonne": round(signaux["hors_colonne"], 2), + "accord": round(accord, 2), + } + rendu.append( + { + "numero": rang, + "apercu": apercu, + "pleines": sum(1 for v in ligne if classer(v) != "vide"), + "mesure": mesure, + } + ) + return rendu + + +def _mesure_de_la_ligne(feuille): + """Les cinq mesures de la ligne de champs, ou None. + + Affichées pour qu'une INVENTION se lise avant d'être consentie : la + mesure peut prendre une ligne de données pour un en-tête, et + l'opérateur doit voir POURQUOI elle a tranché avant de la contredire. + """ + rang = feuille.ligne_champs + if rang is None: + return None + signaux = _signaux_entete(feuille.lignes, rang) + accord = _accord_de_forme(feuille.lignes, rang) + if signaux is None or accord is None: + return None + return { + cle: round(valeur, 2) + for cle, valeur in list(signaux.items()) + [("accord", accord)] + if cle != "compares" + } + + +def _hors_cellules_xlsx(classeur, chemin): + """Ce qui porte du texte sans être une cellule. + + Les plages nommées se lisent dans les DEUX collections : openpyxl 3.1 a + scindé l'espace de noms, et `ws.defined_names` porte les noms locaux à + une feuille, invisibles à `wb.defined_names`. + + Les liens externes et les hyperliens de cellule se comptent SÉPARÉMENT : + ce sont des objets différents, et un seul des deux tombe avec + `keep_links=False`. + """ + plages = list(classeur.defined_names.keys()) + hyperliens, cibles, commentaires, auteurs = 0, set(), 0, set() + entetes, validations, conditionnelles, graphiques = 0, 0, 0, 0 + croises = 0 + for onglet in classeur.worksheets: + plages.extend(onglet.defined_names.keys()) + graphiques += len(getattr(onglet, "_charts", []) or []) + croises += len(getattr(onglet, "_pivots", []) or []) + validations += len( + getattr(onglet.data_validations, "dataValidation", []) or [] + ) + conditionnelles += sum(1 for _ in onglet.conditional_formatting) + for entete in ( + onglet.oddHeader, + onglet.evenHeader, + onglet.firstHeader, + onglet.oddFooter, + onglet.evenFooter, + onglet.firstFooter, + ): + for partie in (entete.left, entete.center, entete.right): + if getattr(partie, "text", None): + entetes += 1 + for ligne in onglet.iter_rows(): + for cellule in ligne: + if cellule.comment is not None: + commentaires += 1 + if cellule.comment.author: + auteurs.add(cellule.comment.author) + if cellule.hyperlink is not None: + hyperliens += 1 + cible = getattr(cellule.hyperlink, "target", None) + if cible: + cibles.add(str(cible)) + proprietes = classeur.properties + return { + "createur": proprietes.creator, + "modifie_par": proprietes.lastModifiedBy, + "titre": proprietes.title, + "mots_cles": proprietes.keywords, + "proprietes_perso": len( + list(getattr(classeur, "custom_doc_props", []) or []) + ), + "commentaires": commentaires, + "auteurs_commentaires": len(auteurs), + "hyperliens": hyperliens, + "cibles_liens": sorted(cibles)[:20], + "liens_externes": len(getattr(classeur, "_external_links", []) or []), + "plages_nommees": sorted(set(plages))[:40], + "entetes_pieds": entetes, + "validations": validations, + "conditionnelles": conditionnelles, + "graphiques": graphiques, + "croises": croises, + "feuilles_graphiques": len(getattr(classeur, "chartsheets", []) or []), + "images": noyau.compter_media(chemin), + "macros": has_macros(chemin), + } + + +def report(chemin): + """Le rapport, avant toute question.""" + format_lu = detect_format(chemin) + if format_lu == "protege": + raise ErreurMoteur("protege", chemin) + if format_lu == "xlsb": + rapport = _rapport_commun(chemin, "xlsb") + rapport["hors_cellules"] = {"macros": has_macros(chemin)} + rapport["arret"] = noyau.ERREURS["illisible_ici"] + return rapport + if not format_lu: + raise ErreurMoteur("format_inconnu", os.path.basename(chemin)) + + rapport = _rapport_commun(chemin, format_lu) + if format_lu == "xlsx": + progres(os.path.basename(chemin)) + classeur, feuilles = _lire_xlsx(chemin) + rapport["hors_cellules"] = _hors_cellules_xlsx(classeur, chemin) + elif format_lu == "xls": + feuilles = _lire_xls(chemin) + rapport["avertissements"].append( + "This format stores no formula readable here." + ) + elif format_lu == "access": + feuilles = _lire_access(chemin) + requetes = requetes_access(chemin) + if requetes is None: + rapport["avertissements"].append( + "Saved queries cannot be counted here:" + " install mdbtools to know whether this file holds any." + ) + elif requetes: + rapport["requetes"] = requetes + rapport["avertissements"].append( + "Saved queries are not carried over to the copy." + ) + elif format_lu == "csv": + feuille, meta = _lire_csv(chemin) + feuilles = [feuille] + rapport.update(meta) + elif format_lu == "json": + feuilles = _lire_json(chemin)[0] + elif format_lu == "xml": + feuilles = _lire_xml(chemin)[0] + else: # pragma: no cover - detect_format ne rend rien d'autre + raise ErreurMoteur("format_inconnu", format_lu) + + # AVANT les statistiques : elles sautent l'empan d'en-tête, et une + # ligne d'en-tête restée dans la mesure rendrait `forme_identifiant` + # faux pour toute la colonne — le plancher lâcherait alors les + # colonnes pour lesquelles il existe. + mesurer_entetes(feuilles, format_lu) + rapport["feuilles"] = _feuilles_en_rapport(feuilles) + rapport["comptes"] = _comptes_de(feuilles) + if not rapport["vivier_complet"]: + rapport["avertissements"].append( + "Only 20 fallback words are available: randomwordfr is missing." + ) + return rapport + + +# ---------------------------------------------------------------------- +# JSON et XML — hors tableur, une valeur est aussi un attribut +# ---------------------------------------------------------------------- +def _lire_json(chemin): + """Un JSON ramené à une grille, pour que le rapport parle. + + Les CLÉS sont de la structure et ne bougent pas ; seules les valeurs + passent par les règles. + """ + with open(chemin, "r", encoding="utf-8") as fh: + arbre = json.load(fh) + lignes = [] + ancres = {} + colonne_structure = None + ligne1_fabriquee = False + porteur = None + # Le tableau d'enregistrements exige que TOUTE entrée soit un objet : + # une entrée nue (chaîne, nombre, null, liste) n'a pas de clés, et + # `element.get` la faisait mourir. Un tableau mêlé retombe sur + # l'aplatissement, qui ancre chaque feuille — la sauter écrirait + # l'entrée en clair, non annoncée. + if ( + isinstance(arbre, list) + and arbre + and all(isinstance(e, dict) for e in arbre) + ): + cles = [] + for element in arbre: + for cle in element: + if cle not in cles: + cles.append(cle) + lignes.append(cles) + for element in arbre: + lignes.append([element.get(cle) for cle in cles]) + for index, cle in enumerate(cles, start=1): + if cle in element: + ancres[(len(lignes), index)] = (element, cle) + else: + colonne_structure = 1 + ligne1_fabriquee = True + lignes.append(["cle", "valeur"]) + porteur = None if isinstance(arbre, (dict, list)) else [arbre] + for cle, valeur, conteneur, index in _aplatir_json( + arbre, + conteneur=porteur, + index=None if porteur is None else 0, + ): + lignes.append([cle, valeur]) + ancres[(len(lignes), 2)] = (conteneur, index) + feuille = Feuille(NOM_FEUILLE_NEUTRE, lignes) + feuille.ancres = ancres + feuille.arbre = arbre + feuille.colonne_structure = colonne_structure + feuille.ligne1_fabriquee = ligne1_fabriquee + feuille.porteur = porteur + return [feuille], arbre + + +def _aplatir_json(noeud, prefixe="", conteneur=None, index=None): + """(chemin, valeur, conteneur, clé) — la clé permet de RÉÉCRIRE.""" + if isinstance(noeud, dict): + for cle, valeur in noeud.items(): + yield from _aplatir_json( + valeur, f"{prefixe}.{cle}".strip("."), noeud, cle + ) + elif isinstance(noeud, list): + for rang, valeur in enumerate(noeud): + yield from _aplatir_json(valeur, f"{prefixe}[{rang}]", noeud, rang) + else: + yield prefixe, noeud, conteneur, index + + +def _lire_xml(chemin): + """(feuilles, arbre). L'analyse désamorce les entités externes.""" + from defusedxml.ElementTree import parse + + arbre = parse(chemin) + racine = arbre.getroot() + lignes = [["chemin", "valeur"]] + ancres = {} + for element in racine.iter(): + if element.text and element.text.strip(): + lignes.append([element.tag, coercer_texte(element.text.strip())]) + ancres[(len(lignes), 2)] = (element, None) + for cle, valeur in element.attrib.items(): + lignes.append([f"{element.tag}@{cle}", coercer_texte(valeur)]) + ancres[(len(lignes), 2)] = (element, cle) + # La QUEUE d'un élément : le texte qui suit sa balise fermante. Un + # export d'ERP nommé « .xls » qui est en réalité du HTML arrive + # ici, et la moitié d'une cellule y vit — « Client X Nom » + # porte « Nom » en queue de . + if element.tail and element.tail.strip(): + lignes.append( + [f"{element.tag}#tail", coercer_texte(element.tail.strip())] + ) + ancres[(len(lignes), 2)] = (element, "#tail") + feuille = Feuille(NOM_FEUILLE_NEUTRE, lignes) + feuille.ancres = ancres + feuille.arbre = arbre + feuille.colonne_structure = 1 + feuille.ligne1_fabriquee = True + return [feuille], arbre + + +# ---------------------------------------------------------------------- +# Le nettoyage hors cellules +# ---------------------------------------------------------------------- +def nettoyer_hors_cellules(classeur, table, options): + """Effacer ce qui porte du texte sans être une cellule. AVANT la grille. + + Sur les 24 vecteurs qu'un `.xlsx` peut porter, ce bloc en efface 19. + Les cinq qui restent — plages nommées globale et locale, nom de + tableau, littéral de formule, nom de feuille — sont référencés par des + formules : les supprimer casserait ce que la règle de la formule vient + de préserver, donc ils sont RAPPORTÉS et non effacés. + + La liste qui suit tient à un attribut privé près, et c'est le test de + fuite de `test_transform_external.py` qui la garde : il assert la liste + EXACTE des survivants, de sorte qu'un vecteur rouvert par une montée de + version d'openpyxl fait tomber le test au lieu de passer inaperçu. + """ + from openpyxl.packaging.core import DocumentProperties + from openpyxl.packaging.custom import CustomPropertyList + + vivier = options["vivier"] + comptes = {"proprietes": 0, "commentaires": 0, "hyperliens": 0} + + # `creator` vaut « openpyxl » par DÉFAUT : sans creator=None, l'élément + # ne disparaît pas, il est REMPLI. Mesuré. + # + # Les DATES sont figées, et pour deux raisons. Elles disent l'instant + # où la copie a été tirée, donc quelque chose de la séance de + # l'opérateur, que rien n'oblige à transmettre. Et laissées à + # `datetime.now()`, elles rendaient le refus du filet ALÉATOIRE : une + # cellule qui porte « 2026-09 » — un libellé de période, ordinaire + # dans un export comptable — se retrouvait dans l'horodatage de la + # copie, et le compte en surplus du socle basculait d'une exécution à + # l'autre selon que `created` et `modified` tombaient sur la même + # seconde. Une copie identique était tantôt écrite, tantôt refusée. + classeur.properties = DocumentProperties( + creator=None, created=EPOQUE_FIGEE, modified=EPOQUE_FIGEE + ) + classeur.custom_doc_props = CustomPropertyList() + comptes["proprietes"] += 1 + + for onglet in classeur.worksheets: + onglet._pivots = [] + onglet._images = [] + onglet.auto_filter.filterColumn = [] + onglet.data_validations.dataValidation = [] + onglet.conditional_formatting._cf_rules.clear() + for entete in ( + onglet.oddHeader, + onglet.evenHeader, + onglet.firstHeader, + onglet.oddFooter, + onglet.evenFooter, + onglet.firstFooter, + ): + for partie in (entete.left, entete.center, entete.right): + partie.text = None + if options.get("garder_graphiques"): + _nettoyer_graphiques(onglet) + else: + onglet._charts = [] + for ligne in onglet.iter_rows(): + for cellule in ligne: + if cellule.comment is not None: + cellule.comment = None + comptes["commentaires"] += 1 + if cellule.hyperlink is not None: + cellule.hyperlink = None + comptes["hyperliens"] += 1 + _anonymiser_noms_locaux(onglet, table, vivier) + + _anonymiser_noms_locaux(classeur, table, vivier) + comptes["formats"] = _anonymiser_formats_de_nombre(classeur, table, vivier) + comptes["styles"] = _renommer_styles_nommes(classeur) + comptes["styles_tableau"] = _renommer_styles_de_tableau(classeur) + comptes["polices"] = _renommer_polices(classeur) + comptes["theme"] = _assainir_theme(classeur) + return comptes + + +def _assainir_theme(classeur): + """Le thème, que le graveur recopie octet pour octet. + + openpyxl rend `xl/theme/theme1.xml` tel qu'il l'a LU quand + `loaded_theme` est rempli, et son propre défaut sinon. Deux chaînes + libres y vivent : le nom sous lequel le thème a été enregistré, et les + polices majeure et mineure. Une police de marque y reste donc nommée + après que le renommage l'a retirée des styles, et une de ces chaînes + qui répète une valeur de la grille rend le classeur inécrivable au + filet, sans qu'aucune règle ne puisse l'assainir. + + Le remède est de laisser openpyxl écrire SON thème : la copie perd la + palette du client, ce qui est exactement ce qu'on veut d'une copie + transmissible. + """ + if not getattr(classeur, "loaded_theme", None): + return 0 + classeur.loaded_theme = None + return 1 + + +# Les caractères qui « avalent » le suivant dans un format de nombre : +# l'échappement, la réservation de largeur, la répétition. Un libellé peut +# s'écrire ainsi, hors guillemets et un caractère à la fois. +_ECHAPPE_FORMAT = "\\_*" + +# Une section de devise : `[$USD-409]`, `[$-1010409]`. Son texte est libre — +# un nom de client y tient — alors que `[Red]`, `[<100]` et `[h]` sont des +# mots-clés qu'un remplacement casserait. +_DEVISE_CROCHET = re.compile(r"^\[\$(.*?)(-[0-9A-Fa-f]+)?\]$") + +# Une section de devise qu'Excel écrit ENTIÈREMENT de convention : un +# symbole ou un code, suivi d'un modificateur de locale. Le seul LCID +# hexadécimal ne les couvre pas — « [$-en-US] », « [$-x-sysdate] », +# « [$€-x-euro2] », « [$R$-pt-BR] ». Aucune ne porte de donnée du +# client, et les remplacer détruit le symbole monétaire de la copie, +# ou la forme de ses dates. Reconnue en ENTIER, jamais par un suffixe +# élargi : « [$Cabinet-Lav] » y perdrait la moitié de son libellé. +_DEVISE_LOCALE = re.compile( + r"^\[\$[^\]-]{0,4}-(?:[0-9A-Fa-f]+|x-[a-z0-9]+" + r"|[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*)\]$" +) + + +# Excel accepte du texte NU dans un format de nombre — sans guillemets ni +# barre oblique — et un nom écrit là sortait intact. Le reconnaître demande +# de distinguer un mot d'un motif de date, et les deux sont faits de +# lettres. Un ALPHABET ne le fait pas : Excel traduit ses marques de +# position dans la langue du classeur, si bien que « jj/mm/aaaa » et +# « tt.mm.jjjj » sont des dates aussi légitimes que « dd/mm/yyyy », et +# énumérer les lettres de chaque locale se perd — pour ensuite détruire la +# date de la copie sur celle qu'on a oubliée. +# +# Ce qui sépare les deux est la RÉPÉTITION. Une marque de position vient +# par groupes d'une même lettre, seuls ou collés — « aaaammjj », « hhmm ». +# Un mot colle des lettres différentes et retombe sur des groupes d'UNE +# seule — « Nom », « aboulie ». D'où le test : deux lettres différentes +# côte à côte ET un groupe solitaire. +# +# L'erreur reste orientée. Prendre un mot pour un motif le laisse passer, +# et le filet de relecture refuse la copie ; prendre un motif pour un mot +# abîme le classeur en silence. +_LETTRES_FORMAT = re.compile(r"[^\W\d_]+") + +# Ce qu'Excel écrit en lettres sans que ce soit un libellé, malgré des +# lettres différentes côte à côte. +_MOTS_CLES_FORMAT = frozenset(("general", "am", "pm", "a", "p")) + + +def _est_un_mot(lettres): + """Vrai si cette suite de lettres est un mot et non une marque.""" + if lettres.lower() in _MOTS_CLES_FORMAT: + return False + plie = lettres.lower() + if not any(a != b for a, b in zip(plie, plie[1:])): + return False + return any( + len(trouve.group()) < 2 for trouve in re.finditer(r"(.)\1*", plie) + ) + + +# Ce qui peut joindre deux mots d'un même libellé sans être du motif. +# Tout le reste sépare : bloquer la jonction ne coûte qu'un remplacement +# de plus, la forcer emporte le motif qui vit entre les deux. +_JOINT_LIBELLE = re.compile("^[\\s'\u2019-]*$") + + +def _spans_du_libelle(plage): + """Les étendues des libellés nus de cette suite, de gauche à droite. + + Deux mots séparés d'un simple espace font UN libellé — « Nom du + client » — mais deux mots séparés d'un motif en font DEUX : la + tranche qui les réunirait emporterait ce motif, et « #,##0 X;-#,##0 + X » perdait sa section négative en entier. + """ + spans = [] + for trouve in _LETTRES_FORMAT.finditer(plage): + if not _est_un_mot(trouve.group()): + continue + debut, fin = trouve.span() + if spans and _JOINT_LIBELLE.match(plage[spans[-1][1] : debut]): + spans[-1] = (spans[-1][0], fin) + else: + spans.append((debut, fin)) + return spans + + +# Les jetons qu'une section entre crochets peut porter. Contrairement aux +# marques de position d'une date, la grammaire d'Excel les ÉNUMÈRE : huit +# couleurs, cinquante-six numérotées, une condition, une durée écoulée, un +# jeu de chiffres, une ère. C'est une spécification, pas une devinette, +# d'où l'énumération ici là où l'alphabet a été rejeté ailleurs. +# +# Tout jeton de trois caractères ou moins passe de toute façon, le seuil du +# filet le laissant : la liste ne sert que ceux qui l'atteignent. +_CROCHETS_CONNUS = frozenset( + ( + "black", + "blue", + "cyan", + "green", + "magenta", + "red", + "white", + "yellow", + "thai", + "hijri", + "buddhist", + "gregorian", + ) +) + +_CROCHET_NUMEROTE = re.compile( + r"^(?:color\s?(?:[1-9]|[1-4][0-9]|5[0-6])" + r"|(?:db|nat)num(?:1[0-9]|[1-9]))$" +) + +# Une condition, qu'une comparaison ouvre : `[<100]`, `[>=0]`, `[<>1]`. +_CROCHET_CONDITION = re.compile(r"^[<>=]") + + +def _crochet_est_connu(interieur): + """Vrai si la grammaire d'Excel explique cette section.""" + plie = interieur.strip().lower() + if len(plie) < noyau.LONGUEUR_VERIFIABLE: + return True + if _CROCHET_CONDITION.match(plie): + return True + return plie in _CROCHETS_CONNUS or bool(_CROCHET_NUMEROTE.match(plie)) + + +def _parcourir_format(fmt, mot_si_long): + r"""Le format parcouru de gauche à droite, ses libellés assainis. + + Excel a QUATRE façons de porter du texte dans un format de nombre, et + une expression sur les seuls guillemets n'en voyait qu'une : + + - `"..."` — la forme courante ; + - `\N\o\m` — une suite d'échappements, un caractère à la fois, sans + aucun guillemet : c'est ainsi qu'un libellé écrit à la main dans + Excel arrive souvent ; + - `[$Nom-409]` — la section de devise, dont le texte est libre ; + - un guillemet ÉCHAPPÉ, qui n'ouvre pas de littéral et décalait + l'appariement de tous les suivants. + + Les autres sections entre crochets sont recopiées telles quelles : y + remplacer `Red` ou `h` casserait le format. + """ + sortie = [] + plat = [] + + def vider_plat(): + """Le texte nu accumulé : ses libellés partent, son motif reste.""" + if not plat: + return + brut = "".join(plat) + plat.clear() + curseur = 0 + for debut, fin in _spans_du_libelle(brut): + mot = mot_si_long(brut[debut:fin]) + if mot is None: + continue + sortie.append(brut[curseur:debut]) + sortie.append('"%s"' % mot) + curseur = fin + sortie.append(brut[curseur:]) + + index, taille = 0, len(fmt) + while index < taille: + caractere = fmt[index] + if caractere in _ECHAPPE_FORMAT and index + 1 < taille: + vider_plat() + debut = index + lettres = [] + while index + 1 < taille and fmt[index] in _ECHAPPE_FORMAT: + lettres.append(fmt[index + 1]) + index += 2 + mot = mot_si_long("".join(lettres)) + sortie.append(fmt[debut:index] if mot is None else '"%s"' % mot) + continue + if caractere == "[": + vider_plat() + fin = fmt.find("]", index) + if fin == -1: + sortie.append(fmt[index:]) + break + section = fmt[index : fin + 1] + if _DEVISE_LOCALE.match(section): + sortie.append(section) + index = fin + 1 + continue + devise = _DEVISE_CROCHET.match(section) + if devise: + mot = mot_si_long(devise.group(1)) + if mot is not None: + section = "[$%s%s]" % (mot, devise.group(2) or "") + elif not _crochet_est_connu(section[1:-1]): + # Une section que la grammaire n'explique pas porte du + # texte libre : Excel ne l'écrit pas, un producteur tiers + # si, et elle sortait intacte. Le remplacement reste NU — + # un crochet ne porte pas de guillemets, et la section + # était déjà hors grammaire avant qu'on y touche. + mot = mot_si_long(section[1:-1]) + if mot is not None: + section = "[%s]" % mot + sortie.append(section) + index = fin + 1 + continue + if caractere == '"': + vider_plat() + fin = fmt.find('"', index + 1) + interieur = fmt[index + 1 :] if fin == -1 else fmt[index + 1 : fin] + mot = mot_si_long(interieur) + sortie.append('"%s' % (interieur if mot is None else mot)) + if fin == -1: + break + sortie.append('"') + index = fin + 1 + continue + plat.append(caractere) + index += 1 + vider_plat() + return "".join(sortie) + + +def _anonymiser_formats_de_nombre(classeur, table, vivier): + """Le texte libre d'un format de nombre personnalisé. + + Excel laisse suffixer un nombre d'un libellé — `#,##0" Nom du client"` — + et ce libellé vit dans `xl/styles.xml`, hors de toute cellule. Aucune + règle ne le voyait, et le filet le refusait sans jamais l'assainir : un + classeur portant un nom dans un format personnalisé était inécrivable. + + La liste est REMPLACÉE dans son ordre, jamais réécrite cellule par + cellule : les cellules référencent un format par son INDEX, et le + setter d'openpyxl AJOUTE une entrée plutôt que de modifier la sienne — + l'ancien format, littéral compris, repartait alors dans le fichier. + + Un littéral court — une devise, une unité — reste : il ne porte aucune + donnée du client, et le remplacer abîmerait le classeur sans rien + protéger. Le seuil est celui du filet, pour que ce qu'on garde ici soit + exactement ce qu'il ne refusera pas. + """ + from openpyxl.utils.indexed_list import IndexedList + + from script.data.external_file import nouveau_mot + + touches = 0 + + def mot_si_long(interieur): + """Le libellé assaini, ou None si le seuil du filet le laisse.""" + nonlocal touches + noyau_texte = interieur.strip() + if len(noyau_texte) < noyau.LONGUEUR_VERIFIABLE: + return None + touches += 1 + tete = interieur[: len(interieur) - len(interieur.lstrip())] + queue = interieur[len(interieur.rstrip()) :] + return "%s%s%s" % ( + tete, + nouveau_mot(noyau_texte, table, vivier), + queue, + ) + + formats = list(getattr(classeur, "_number_formats", []) or []) + if formats: + classeur._number_formats = IndexedList( + [_parcourir_format(f, mot_si_long) for f in formats] + ) + # Un style DIFFÉRENTIEL porte son propre format : c'est celui d'une mise + # en forme conditionnelle. Vider la liste casserait l'index qu'un style + # de tableau personnalisé y référence, donc on l'assainit en place. + for style in ( + getattr( + getattr(classeur, "_differential_styles", None), "styles", None + ) + or [] + ): + fmt = getattr(style, "numFmt", None) + if fmt is not None and getattr(fmt, "formatCode", None): + fmt.formatCode = _parcourir_format(fmt.formatCode, mot_si_long) + for onglet in classeur.worksheets: + for graphique in getattr(onglet, "_charts", []) or []: + _formats_de_graphique(graphique, mot_si_long) + return touches + + +def _formats_de_graphique(graphique, mot_si_long): + """Les formats de nombre qu'un graphique porte hors des cellules. + + Un axe et une étiquette de données portent leur PROPRE format, dont le + libellé vit dans `xl/charts/chartN.xml` : ni dans la liste que le + classeur tient, ni dans une cellule. Le filet ne le rattrape pas, car + il ne refuse que ce qui a été ANNONCÉ remplacé — un libellé qui n'a + jamais été lu d'une cellule n'est annoncé par personne, et sort intact. + + Deux formes cohabitent : un objet à `formatCode` sur un axe, une + chaîne nue sur une étiquette. Le parcours descend le graphe que + openpyxl sérialise, et s'amorce sur les axes : le graphique ne les + référence que par leur identifiant, ils ne sont pas dans ses éléments. + """ + vus = set() + pile = [graphique] + for nom in ("x_axis", "y_axis", "z_axis"): + axe = getattr(graphique, nom, None) + if axe is not None: + pile.append(axe) + while pile: + porteur = pile.pop() + if id(porteur) in vus: + continue + vus.add(id(porteur)) + fmt = getattr(porteur, "numFmt", None) + if isinstance(fmt, str): + porteur.numFmt = _parcourir_format(fmt, mot_si_long) + elif getattr(fmt, "formatCode", None): + fmt.formatCode = _parcourir_format(fmt.formatCode, mot_si_long) + for element in getattr(porteur, "__elements__", ()) or (): + valeur = getattr(porteur, element, None) + if not isinstance(valeur, (list, tuple)): + valeur = (valeur,) + for candidat in valeur: + if hasattr(candidat, "__elements__"): + pile.append(candidat) + + +# Ce qu'installent Office, LibreOffice et les systèmes, plus les +# métriques-compatibles libres. Une police absente de cette liste devient +# `police_` : la copie perd son apparence, ce qui est le côté sur +# lequel pencher quand le nom peut être celui d'une fonte de marque. +POLICES_COURANTES = frozenset( + ( + "Aptos", + "Aptos Display", + "Aptos Narrow", + "Arial", + "Arial Black", + "Arial Narrow", + "Bahnschrift", + "Bookman Old Style", + "Cabin", + "Calibri", + "Calibri Light", + "Cambria", + "Cambria Math", + "Candara", + "Carlito", + "Caladea", + "Century Gothic", + "Comic Sans MS", + "Consolas", + "Constantia", + "Corbel", + "Courier", + "Courier New", + "DejaVu Sans", + "DejaVu Sans Mono", + "DejaVu Serif", + "Ebrima", + "Franklin Gothic Book", + "Garamond", + "Georgia", + "Gill Sans MT", + "Helvetica", + "Helvetica Neue", + "Impact", + "Inconsolata", + "Lato", + "Liberation Mono", + "Liberation Sans", + "Liberation Sans Narrow", + "Liberation Serif", + "Lucida Console", + "Lucida Sans Unicode", + "MS Gothic", + "MS PGothic", + "MS Sans Serif", + "MS Serif", + "Malgun Gothic", + "Menlo", + "Meiryo", + "Monaco", + "Noto Sans", + "Noto Serif", + "Open Sans", + "Palatino Linotype", + "PT Sans", + "Roboto", + "SimSun", + "Segoe UI", + "Segoe UI Light", + "Segoe UI Semibold", + "Segoe UI Symbol", + "Source Sans Pro", + "Sylfaen", + "Symbol", + "Tahoma", + "Times", + "Times New Roman", + "Trebuchet MS", + "Ubuntu", + "Ubuntu Mono", + "Verdana", + "Webdings", + "Wingdings", + "Wingdings 2", + "Wingdings 3", + "Yu Gothic", + ) +) + + +def _renommer_polices(classeur): + """Le NOM d'une police part aussi dans les styles. + + Une police installée chez le client porte son nom, et rien ne la + référence par autre chose que ce nom. Un repère POSITIONNEL suffit — + la faire passer par la table brûlerait des mots du vivier sur ce qui + n'est pas une donnée de la grille, et ferait tolérer ce mot par le + filet là où il n'a rien à excuser. + + Aucune forme ne sépare « Century Gothic » d'une fonte de marque : les + deux sont des noms propres. Le renommage porte donc sur TOUTES celles + que la liste ne nomme pas, et l'erreur penche du côté du dégât + cosmétique plutôt que du nom qui sort. `POLICES_COURANTES` n'est pas + la propriété de sûreté, seulement le confort de la copie : ce qu'elle + oublie perd son apparence, jamais sa donnée. + """ + touches = 0 + rang = 0 + # Un style DIFFÉRENTIEL porte sa police EN LIGNE : elle vit dans + # `xl/styles.xml` sans passer par la liste des polices du classeur, si + # bien qu'une fonte de marque survivait au renommage en n'étant nommée + # que par une mise en forme conditionnelle. + for porteur in list(getattr(classeur, "_fonts", []) or []) + [ + getattr(style, "font", None) + for style in ( + getattr( + getattr(classeur, "_differential_styles", None), + "styles", + None, + ) + or [] + ) + ]: + if porteur is None: + continue + rang += 1 + nom = getattr(porteur, "name", None) + if nom and nom not in POLICES_COURANTES: + porteur.name = f"police_{rang}" + touches += 1 + return touches + + +def _renommer_styles_de_tableau(classeur): + """Le NOM d'un style de tableau personnalisé, à ses TROIS endroits. + + Il vit dans les styles du classeur — l'entrée du style et, quand il est + le défaut, l'attribut qui le désigne — et dans chaque partie de tableau + qui le référence. Aucune formule ne le résout, contrairement au nom + d'affichage d'un tableau : il se renomme, il ne se rapporte pas. Les + trois endroits changent dans la même passe, sinon le tableau perd sa + mise en forme. + + La liste ne porte que ce que le DOCUMENT définit : les styles intégrés + n'y figurent jamais, donc chaque entrée est un nom tapé par quelqu'un. + """ + liste = getattr(classeur, "_table_styles", None) + correspondance = {} + for rang, style in enumerate( + getattr(liste, "tableStyle", None) or (), start=1 + ): + if style.name: + correspondance[style.name] = style.name = f"tableStyle_{rang}" + if not correspondance: + return 0 + for attribut in ("defaultTableStyle", "defaultPivotStyle"): + valeur = getattr(liste, attribut, None) + if valeur in correspondance: + setattr(liste, attribut, correspondance[valeur]) + for onglet in classeur.worksheets: + for tableau in (getattr(onglet, "tables", {}) or {}).values(): + info = getattr(tableau, "tableStyleInfo", None) + nom = getattr(info, "name", None) + if nom in correspondance: + info.name = correspondance[nom] + return len(correspondance) + + +def _renommer_styles_nommes(classeur): + """Le NOM d'un style nommé part aussi dans `xl/styles.xml`. + + Renommé en place : une cellule référence son style par l'index de la + liste, que renommer l'objet ne déplace pas. « Normal » est le style par + défaut d'Excel et n'est pas un nom donné par quelqu'un. + """ + correspondance = {} + for rang, style in enumerate( + getattr(classeur, "_named_styles", []) or [], start=1 + ): + if style.name != "Normal": + correspondance[style.name] = style.name = f"style_{rang}" + if not correspondance: + return 0 + # Une COLONNE de tableau référence un style nommé par son NOM — sur le + # tableau et sur chacune de ses colonnes, pour l'en-tête, les données + # et la ligne de total. Sans cette passe, l'ancien nom — celui du + # client — reste dans la partie de tableau, et la référence ne résout + # plus. + for onglet in classeur.worksheets: + for tableau in (getattr(onglet, "tables", {}) or {}).values(): + porteurs = [tableau] + list( + getattr(tableau, "tableColumns", None) or [] + ) + for porteur in porteurs: + for attribut in ( + "headerRowCellStyle", + "dataCellStyle", + "totalsRowCellStyle", + "headerRowDxfId", + ): + valeur = getattr(porteur, attribut, None) + if valeur in correspondance: + setattr(porteur, attribut, correspondance[valeur]) + return len(correspondance) + + +def _nettoyer_graphiques(onglet): + """Le chemin optionnel : garder le graphique, vider ce qu'il porte. + + `s.tx = None` est INDISPENSABLE et le nettoyage des caches ne le + remplace pas : un titre de série a deux formes — `littéral` + quand il est tapé, `réf` quand il vient des données + — et ni l'une ni l'autre n'est un cache : vider `strCache` et + `numCache` les laisse toutes deux en place. + """ + for graphique in onglet._charts: + graphique.title = None + for axe in ( + getattr(graphique, "x_axis", None), + getattr(graphique, "y_axis", None), + getattr(graphique, "z_axis", None), + ): + if axe is not None: + axe.title = None + for serie in graphique.series: + for source in ( + serie.cat, + serie.val, + getattr(serie, "xVal", None), + getattr(serie, "yVal", None), + getattr(serie, "bubbleSize", None), + ): + if source is None: + continue + for reference in ("numRef", "strRef", "multiLvlStrRef"): + porteur = getattr(source, reference, None) + if porteur is None: + continue + for cache in ( + "numCache", + "strCache", + "multiLvlStrCache", + ): + if hasattr(porteur, cache): + setattr(porteur, cache, None) + serie.tx = None + + +def _anonymiser_noms_locaux(porteur, table, vivier): + """La constante littérale d'une plage nommée passe par la table. + + Le NOM ne se supprime pas — une formule le référencerait dans le vide, + alors que la règle de la formule vient de la préserver. Sa VALEUR, en + revanche, se remplace quand c'est une constante littérale, et le nom + reste résolvable. Mesuré comme survivant tant qu'on ne le fait pas. + """ + from script.data.external_file import nouveau_mot + + noms = getattr(porteur, "defined_names", None) + if not noms: + return + for nom in list(noms.keys()): + defini = noms[nom] + texte = getattr(defini, "attr_text", "") or "" + if len(texte) > 1 and texte.startswith('"') and texte.endswith('"'): + interieur = texte[1:-1] + if interieur: + defini.attr_text = '"%s"' % nouveau_mot( + interieur, table, vivier + ) + + +# ---------------------------------------------------------------------- +# La marche à blanc et l'écriture +# ---------------------------------------------------------------------- +def _rang_de_colonne(valeur): + """Un rang de colonne 1-based, ou None si ce n'en est pas un.""" + try: + rang = int(valeur) + except (TypeError, ValueError): + return None + return rang if rang >= 1 else None + + +def _preparer(chemin, options): + """(format, feuilles, classeur, rapport, options complétées).""" + format_lu = detect_format(chemin) + if format_lu == "protege": + raise ErreurMoteur("protege", chemin) + if format_lu == "xlsb": + raise ErreurMoteur("illisible_ici", chemin) + if not format_lu: + raise ErreurMoteur("format_inconnu", os.path.basename(chemin)) + + rapport = report(chemin) + options = dict(options) + options["vivier"] = vivier_de_mots() + options.setdefault("nombres", True) + options.setdefault("texte", True) + options.setdefault("entetes", False) + options["colonnes_intactes"] = set(options.get("colonnes_intactes") or []) + # Un dict {nom: [str]} traverse le sous-processus ; un set et un dict à + # clés tuple sont refusés par `json.dumps`, ce qui est la raison pour + # laquelle les clés dérivées de `_preparer` ne voyagent jamais. + options["colonnes_intactes_par_feuille"] = { + str(nom): {str(c).strip() for c in (liste or []) if str(c).strip()} + for nom, liste in ( + options.get("colonnes_intactes_par_feuille") or {} + ).items() + } + # Des ENTIERS : la réponse de l'écran, qui désigne par l'index parce + # qu'une correction d'en-tête renomme les colonnes. Ce qui ne se lit + # pas comme un rang est sauté — la spec traverse un JSON, et une clé + # illisible ne doit pas emporter le travail. + options["colonnes_intactes_index_par_feuille"] = { + str(nom): { + rang + for rang in (_rang_de_colonne(c) for c in (liste or [])) + if rang + } + for nom, liste in ( + options.get("colonnes_intactes_index_par_feuille") or {} + ).items() + } + + classeur = None + if format_lu == "xlsx": + classeur, feuilles = _lire_xlsx( + chemin, garder_vba=options.get("garder_macros") + ) + elif format_lu == "xls": + feuilles = _lire_xls(chemin) + elif format_lu == "access": + feuilles = _lire_access(chemin) + elif format_lu == "csv": + feuille_csv, meta_csv = _lire_csv(chemin) + feuilles = [feuille_csv] + # Le délimiteur est détecté, imprimé à l'opérateur, puis il servait + # à LIRE et pas à écrire : un fichier à point-virgule revenait en + # virgule, et le tableur du destinataire le rendait en une colonne. + options.setdefault("delimiteur", meta_csv["delimiteur"]) + elif format_lu == "json": + feuilles = _lire_json(chemin)[0] + else: + feuilles = _lire_xml(chemin)[0] + + # La même mesure que dans `report`, et la CORRECTION de l'opérateur + # par-dessus : elle voyage dans les options sérialisées et ne se + # remesure jamais. Posée avant `etiquettes` et `bornes`, qui en + # dérivent par la ligne de champs. + # La table est relue ici, et de nouveau par l'appelant : deux + # lectures d'un fichier qui ne change pas entre les deux, ce qui + # évite de faire traverser un objet à `_preparer` pour un + # dictionnaire de quelques lignes. + mesurer_entetes( + feuilles, + format_lu, + options.get("entetes_par_feuille") or {}, + Correspondance.charger(options.get("table_chemin")).entetes, + ) + _resynchroniser_le_rapport(rapport, feuilles) + connues = {f.nom for f in feuilles} + demandees = options.get("feuilles") or [] + inconnues = [n for n in demandees if n not in connues] + if inconnues: + raise ErreurMoteur("aucune_feuille", ", ".join(inconnues)) + options["feuilles"] = list(demandees) or None + options["etiquettes"] = _etiquettes_par_colonne(feuilles) + options["bornes"] = _bornes_par_colonne(rapport) + options["formes"] = _formes_par_colonne(rapport) + options["formes_relation"] = _formes_par_colonne(rapport, "forme_relation") + options["selections"] = _formes_par_colonne(rapport, "selection") + # Hors tableur, la colonne 1 de la grille porte des NOMS de balise ou des + # chemins de clé : de la structure, que le graveur ne touche jamais. Les + # compter comme remplacées désarmait le refus « rien à faire » et brûlait + # le vivier sur des noms de champ. + _verifier_conversion(format_lu, feuilles, options) + options["colonnes_structure"] = { + (f.nom, f.colonne_structure) for f in feuilles if f.colonne_structure + } + # Hors tableur, l'ancre est la SEULE voie d'écriture, et aucune ancre ne + # porte la ligne 1 : ni l'étiquette que le lecteur a fabriquée, ni les + # clés d'objet d'un tableau d'enregistrements. La compter en portée la + # fait annoncer comme remplacée alors que la copie la garde en clair, et + # le filet qui relit les octets refuse alors TOUTE copie. Une conversion + # sort par un autre graveur, qui écrit bien cette ligne : elle y reste + # en portée. + ecrit_par_ancres = ( + format_lu in ("xml", "json") + and (options.get("conversion") or format_lu) == format_lu + ) + options["lignes_structure"] = { + (f.nom, 1) for f in feuilles if f.ligne1_fabriquee or ecrit_par_ancres + } + # L'empan d'en-tête, en couples, comme `lignes_structure` : bâti ICI, + # donc jamais sérialisé. Une feuille sans en-tête n'y met rien, et sa + # ligne 1 entre en portée — c'est le correctif de la fuite. + options["lignes_entete"] = { + (f.nom, n) for f in feuilles for n in (f.lignes_entete or ()) + } + return format_lu, feuilles, classeur, rapport, options + + +def _verifier_conversion(format_lu, feuilles, options): + """Refuser une cible impossible AVANT l'aperçu. + + Le refus venait du graveur, donc après que l'opérateur avait lu un + aperçu propre et consenti — et sous une clé qui accusait le format + au lieu de nommer la contrainte. + """ + cible = options.get("conversion") or "" + if not cible or cible == format_lu: + return + retenues = [ + f + for f in feuilles + if not options.get("feuilles") or f.nom in options["feuilles"] + ] + if cible == "xml" and len(retenues) > 1: + raise ErreurMoteur("conversion_impossible", f"{len(retenues)} → xml") + + +# Combien de cellules d'empan gardées en clair sont LISTÉES par feuille. +# Par feuille, non globalement : un plafond global cachait une feuille +# entière derrière les entrées d'une autre. Il borne la LISTE, jamais le +# compte — ce qui dépasse est annoncé, faute de quoi l'opérateur consent +# sur un extrait qu'il prend pour le tout. +PLAFOND_GARDEES_LISTEES = 12 + + +def _parcourir( + feuilles, options, table, rng, appliquer=None, gardees_out=None +): + """La passe unique : compte, et écrit si `appliquer` est donné. + + Un seul parcours pour la marche à blanc et pour l'écriture : deux + chemins divergeraient, et c'est la marche à blanc qui perdrait la + confiance qu'on lui accorde. + """ + bilan = { + "remplacees": 0, + "texte": 0, + "nombre": 0, + "hors_portee": 0, + "intactes": {}, + "apercu": [], + "entete_gardee": [], + "entete_gardee_omises": {}, + "colonnes_en_clair": [], + } + # Par feuille : TOUTES les cellules d'empan gardées, listées ou non. + # Le recompte par balayage de la liste était quadratique, et ne + # pouvait de toute façon pas compter au-delà du plafond. + gardees_par_feuille = collections.Counter() + # Par colonne : ce qu'elle porte, et ce qui y a été remplacé. Une + # colonne qui porte quelque chose et dont RIEN n'a été remplacé part + # entière en clair, et le critère vaut quelle que soit la raison — + # plancher, réponse, famille qui traverse par règle, ou aucune règle + # qui l'atteigne. Les compter par famille sur tout le fichier ne + # nommait pas la colonne : sur quarante colonnes, « 7 date(s) + # laissées » ne dit pas laquelle sort. + contenu = collections.Counter() + remplacees = collections.Counter() + for feuille in feuilles: + for numero, ligne in enumerate(feuille.lignes, start=1): + for index, valeur in enumerate(ligne, start=1): + famille = classer(valeur) + if famille == "vide": + continue + # Une ligne d'en-tête n'est pas du contenu de colonne : + # la garder en clair est la RÈGLE, et la compter ici + # ferait dire d'une colonne entièrement remplacée qu'elle + # sort en clair. + if (feuille.nom, numero) not in ( + options.get("lignes_entete") or () + ): + contenu[(feuille.nom, index)] += 1 + if not cellule_en_portee(feuille.nom, numero, index, options): + bilan["hors_portee"] += 1 + if gardees_out is not None: + _noter_gardee(gardees_out, feuille.nom, valeur) + if ( + numero in (feuille.lignes_entete or ()) + and not options.get("entetes") + # TOUTE cellule gardée, non les seules textuelles : + # l'empan garde aussi les lignes de mise en page + # au-dessus de la ligne de champs, et un nombre + # laissé là n'était dit à personne. + and famille != "formule" + and (feuille.nom, numero) + not in (options.get("lignes_structure") or ()) + ): + # L'empan est MESURÉ, et il porte plusieurs lignes + # sur un export mis en page. Un compteur ne dirait + # pas qu'un nom de client est dedans : chaque + # valeur se montre, jusqu'au plafond. + gardees_par_feuille[feuille.nom] += 1 + if ( + gardees_par_feuille[feuille.nom] + <= PLAFOND_GARDEES_LISTEES + ): + bilan["entete_gardee"].append( + { + "feuille": feuille.nom, + # La VRAIE coordonnée : le littéral + # « L1 » mentait dès que l'en-tête + # n'était pas en ligne 1, et + # l'opérateur cherchait la valeur au + # mauvais endroit. + "cellule": f"L{numero}C{index}", + "valeur": valeur_hors_tableur(valeur), + } + ) + continue + bornes = options["bornes"].get((feuille.nom, index)) + if isinstance(valeur, (dict, list)): + # Un conteneur imbriqué n'est pas une cellule : sans + # cette récursion, tout ce qu'il porte sort en clair. + # Le compte des FEUILLES réécrites décide : reconstruit + # à l'identique, le conteneur reste intact. + neuve, compte = _transformer_json( + valeur, options, table, rng + ) + if not compte: + neuve = _INTACTE + else: + neuve = anonymise_cellule( + valeur, options, table, rng, bornes=bornes + ) + compte = {famille: 1} + if neuve is _INTACTE: + bilan["intactes"][famille] = ( + bilan["intactes"].get(famille, 0) + 1 + ) + if gardees_out is not None: + _noter_gardee(gardees_out, feuille.nom, valeur) + continue + remplacees[(feuille.nom, index)] += 1 + for fam, feuilles_reecrites in compte.items(): + bilan["remplacees"] += feuilles_reecrites + if fam in ("texte", "nombre"): + bilan[fam] += feuilles_reecrites + if len(bilan["apercu"]) < 5: + bilan["apercu"].append( + { + "feuille": feuille.nom, + "cellule": f"L{numero}C{index}", + "avant": valeur_hors_tableur(valeur), + "apres": valeur_hors_tableur(neuve), + } + ) + if appliquer is not None: + appliquer(feuille, numero, index, neuve) + bilan["entete_gardee_omises"] = { + nom: compte - PLAFOND_GARDEES_LISTEES + for nom, compte in gardees_par_feuille.items() + if compte > PLAFOND_GARDEES_LISTEES + } + bilan["colonnes_en_clair"] = [ + { + "feuille": nom, + "index": index, + "etiquette": options["etiquettes"].get((nom, index)) or "", + "cellules": porte, + } + for (nom, index), porte in sorted(contenu.items()) + if porte and not remplacees[(nom, index)] + ] + return bilan + + +def _nommer_les_colonnes_ecartees(bilan, rapport, options): + """Les deux listes de colonnes, posées ensemble sur le bilan. + + `colonnes_ecartees` dit celles qu'une RÈGLE explique — le plancher, ou + une réponse de l'opérateur. `colonnes_en_clair` garde alors les + autres : celles qui sortent entières sans que rien de ce qui a été + demandé ne le dise. Les laisser dans les deux listes les faisait + annoncer deux fois, et une ligne redondante apprend à ne plus lire les + autres. + """ + bilan["colonnes_ecartees"] = _colonnes_ecartees(rapport, options) + expliquees = { + (c["feuille"], c["index"]) + for c in bilan["colonnes_ecartees"] + if c.get("index") + } + bilan["colonnes_en_clair"] = [ + c + for c in bilan.get("colonnes_en_clair") or [] + if (c["feuille"], c["index"]) not in expliquees + ] + + +def _colonnes_ecartees(rapport, options): + ecartees = [] + for feuille in rapport.get("feuilles", []): + for colonne in feuille.get("colonnes", []): + if colonne.get("plancher"): + ecartees.append( + { + "feuille": feuille["nom"], + # L'INDEX autant que l'étiquette : c'est par lui + # qu'une colonne se recoupe avec celles qui + # sortent entières en clair, et sans lui aucune + # n'était reconnue comme déjà expliquée. + "index": colonne["index"], + "etiquette": colonne["etiquette"], + "raison": "plancher", + } + ) + elif noyau.colonne_repondue( + feuille["nom"], + colonne["index"], + colonne.get("etiquette"), + options, + ): + # La MÊME règle que la portée : la répéter ici faisait + # trois occasions de divergence. Une colonne sans + # étiquette ne se désigne que par son index, et l'aperçu + # ne la nommait pas du tout. + ecartees.append( + { + "feuille": feuille["nom"], + "index": colonne["index"], + "etiquette": ( + colonne.get("etiquette") or f"#{colonne['index']}" + ), + "raison": "question", + } + ) + return ecartees + + +def plan(chemin, options): + """Ce qui SERAIT écrit. Rien n'est ouvert en écriture. + + La convention du dépôt pour l'action de menu qui écrit : montrer, puis + demander. Une question posée avant de savoir ce qui sera touché n'est + pas un consentement. + """ + import random + + format_lu, feuilles, _classeur, rapport, options = _preparer( + chemin, options + ) + table = Correspondance.charger(options.get("table_chemin")) + graine = options.get("graine") + rng = ( + random.Random(graine) if graine not in (None, "") else random.Random() + ) + fichiers = _fichiers_prevus(chemin, feuilles, options, table) + bilan = _parcourir(feuilles, options, table, rng) + _nommer_les_colonnes_ecartees(bilan, rapport, options) + bilan["format"] = format_lu + bilan["fichiers"] = fichiers + # La marche à blanc est ce sur quoi l'opérateur consent : elle doit + # refuser là où l'écriture refuserait. + _refuser_table_confondue( + chemin, + options.get("table_chemin"), + bilan["fichiers"] + [options.get("destination")], + ) + bilan["avertissements"] = _avertissements(rapport, options) + return bilan + + +def _fichiers_prevus(chemin, feuilles, options, table=None): + """Les chemins qui seront écrits — calculés AVANT toute écriture. + + Les noms passent par la MÊME table que la conversion : un fichier par + feuille est nommé d'après le nom ANONYMISÉ de la feuille, et prédire + d'après le nom d'origine annonçait des chemins qui n'existeraient + jamais. Ce n'était pas qu'un affichage — cette liste est ce que le + contrôle d'écrasement et celui de la table de correspondance + examinent, si bien qu'un fichier réel échappait aux deux. + + L'appel doit précéder le parcours des cellules, dans la marche à + blanc comme à l'écriture : la table sert les deux, et le nom réservé + ici est celui que la conversion retrouvera. + """ + destination = options.get("destination") or "" + cible = options.get("conversion") or "" + if cible and _un_fichier_par_feuille(cible, feuilles, options): + pris = set() + return [ + os.path.join( + destination, + "%s.%s" + % ( + nom_de_fichier_sur( + _nom_de_feuille_anonyme(f, table, options), pris + ), + cible, + ), + ) + for f in feuilles + if not options["feuilles"] or f.nom in options["feuilles"] + ] + return [destination] if destination else [] + + +def _un_fichier_par_feuille(cible, feuilles, options): + retenues = [ + f + for f in feuilles + if not options["feuilles"] or f.nom in options["feuilles"] + ] + return cible in ("csv",) and len(retenues) > 1 + + +# Le plafond de `distinctes` dans `_stats_colonnes`. Au-delà, le compte +# ne dit plus combien la colonne porte de valeurs, et aucune conclusion +# sur sa saturation ne tient. +PLAFOND_DISTINCTES = 10000 + +# À partir de quelle part de l'étendue une colonne d'entiers est dite +# saturée. Pleine, elle ne laisse AUCUNE liberté au tirage ; à neuf +# dixièmes, la copie porte déjà presque le même ensemble. +PART_SATUREE = 0.9 + +# En deçà, une colonne d'entiers pleine ne dit rien de personne : un +# drapeau à deux états, un mois sur douze. L'avertissement y serait du +# bruit, et le bruit finit par se lire comme du fond. +SATURATION_MINIMALE = 20 + + +def _colonnes_saturees(rapport, options): + """Les colonnes d'entiers dont la copie sera une PERMUTATION. + + Le tirage est sans remise et reste dans l'étendue mesurée de la + colonne : avec autant de valeurs distinctes que l'étendue compte + d'entiers, l'ensemble de sortie est forcément l'ensemble d'entrée, et + seule l'affectation change. Ce n'est pas une fuite — la permutation ne + s'inverse pas sans la table — mais l'écran annonce « N nombres + remplacés » et une comparaison d'ENSEMBLES ne montrerait rien. + + Une colonne planchéiée ou laissée intacte n'entre pas : elle n'est pas + remplacée du tout, et `colonnes_ecartees` la nomme déjà. + """ + if not options.get("nombres", True): + return [] + saturees = [] + for feuille in rapport.get("feuilles", []): + if ( + options.get("feuilles") + and feuille["nom"] not in options["feuilles"] + ): + continue + for colonne in feuille.get("colonnes", []): + if colonne.get("plancher") or not colonne.get("entiere"): + continue + etiquette = colonne.get("etiquette") + if noyau.colonne_repondue( + feuille["nom"], colonne["index"], etiquette, options + ): + continue + distinctes = colonne.get("distinctes") or 0 + if distinctes >= PLAFOND_DISTINCTES: + continue + if distinctes < SATURATION_MINIMALE: + continue + etendue = int(colonne["max"]) - int(colonne["min"]) + 1 + if etendue > 0 and distinctes >= etendue * PART_SATUREE: + saturees.append((feuille["nom"], etiquette)) + return saturees + + +def _noms_de_feuille_survivent(rapport, options): + """Le graveur recopie-t-il les noms d'onglet TELS QUELS ? + + Un seul chemin le fait : une source `.xlsx` rendue en `.xlsx` par + `classeur.save`. Toute conversion passe par un classeur NEUF dont les + onglets reçoivent un nom de la table, et `.xls` comme Access n'ont pas + de graveur — leur copie repart par cette même conversion. + + Le dire quand ce n'est pas vrai n'est pas anodin : la liste des + avertissements EST la surface du consentement, et un avis qui parle + d'un risque écarté apprend à ne plus la lire. + """ + if rapport.get("format") != "xlsx": + return False + cible = options.get("conversion") or "" + return not (cible and cible != "xlsx") + + +def _avertissements(rapport, options): + """Ce que la copie perd ou garde, dit plutôt que découvert.""" + dits = [] + if options.get("calibre_chiffres"): + dits.append( + "Numbers keep their digit count, so the column's measured" + " extent no longer bounds them: a rate or a year can leave" + " its range." + ) + if _colonnes_saturees(rapport, options): + dits.append( + "An integer column is saturated: the copy holds the same set" + " of values, only reshuffled." + ) + hors = rapport.get("hors_cellules") or {} + if rapport.get("format") == "xlsx": + dits.append( + "Cached formula results are dropped;" + " the sheet recomputes on open." + ) + dits.append("Document properties were cleared on the copy.") + if hors.get("croises") or hors.get("graphiques"): + dits.append( + "Pivot tables and chart caches are removed: they hold an" + " unanonymised copy of the source." + ) + if hors.get("images"): + dits.append("Images and drawings are not carried over to the copy.") + if hors.get("graphiques") and not options.get("garder_graphiques"): + dits.append("Charts are not carried over to the copy.") + if hors.get("graphiques") and options.get("garder_graphiques"): + dits.append( + "Charts are kept: their caches are cleaned through private" + " attributes." + ) + if hors.get("liens_externes"): + dits.append( + "External links were dropped; formulas that used them" + " show #REF!." + ) + survivent = _noms_de_feuille_survivent(rapport, options) + if hors.get("plages_nommees") and survivent: + dits.append( + "Range and table names are kept so formulas resolve;" + " they may hold identifying strings." + ) + if survivent: + # Le nom d'onglet survit dans workbook.xml, qu'une formule le + # référence ou non : conditionner cet avertissement à la présence + # d'un littéral de formule le taisait sur le cas le plus courant. + dits.append( + "Sheet names are kept so formulas resolve; they may identify." + ) + if options.get("feuilles"): + dits.append( + "Sheets outside the selection are dropped from the copy;" + " formulas that referenced them show #REF!." + ) + if hors.get("feuilles_graphiques"): + dits.append( + "Chart sheets are dropped from the copy: their titles and" + " series caches are not cells." + ) + if rapport.get("format") == "json": + dits.append("Object keys are kept as structure; they may identify.") + if rapport.get("format") == "xml": + dits.append( + "Element and attribute names are kept as structure;" + " they may identify." + ) + encodage = (rapport.get("encodage") or "").lower().replace("-", "_") + if encodage and encodage not in ("utf_8", "utf8", "ascii"): + # Le délimiteur de la source est repris, son encodage NON : la + # copie sort en UTF-8. C'est le bon choix — un mot du vivier ou un + # en-tête gardé peut ne pas s'encoder dans le jeu d'origine, et + # l'écriture échouerait après la question du consentement. Le + # défaut était de ne pas le dire, alors que le rapport annonce + # l'encodage détecté et laisse croire qu'il est conservé. + dits.append("The copy is written in UTF-8, whatever the source was.") + if options.get("garder_macros"): + dits.append( + "The VBA project and its companions (form controls, ActiveX," + " VML shapes, ribbon, EMF images) are copied as they are and" + " were not reviewed." + ) + return dits + + +def _ecrire_atomique(destination, ecrivain): + """Un temporaire du même répertoire, puis `os.replace()`. + + Le `try/finally` n'est pas une politesse : sans lui, une exception + laisse de la donnée client sur le disque, sous un nom que personne + n'annonce. Le mode 0600 dès la création, parce que le temporaire porte + cette donnée avant d'être renommé. + """ + parent = os.path.dirname(os.path.abspath(destination)) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + descripteur, temporaire = tempfile.mkstemp( + dir=parent, prefix=".transform-", suffix=".part" + ) + os.close(descripteur) + os.chmod(temporaire, 0o600) + try: + ecrivain(temporaire) + os.replace(temporaire, destination) + temporaire = None + finally: + if temporaire and os.path.exists(temporaire): + os.unlink(temporaire) + + +def _refuser_si_source(chemin, destination): + """La destination ne peut JAMAIS être la source. + + C'est le seul chemin par lequel l'original disparaîtrait, et la + confirmation par nom — qui porte sur « le fichier existe déjà » — ne le + distinguerait pas d'un écrasement ordinaire. `samefile` couvre le lien + symbolique, `realpath` le couvre avant qu'il existe. + """ + if os.path.exists(destination): + try: + if os.path.samefile(chemin, destination): + raise ErreurMoteur("destination_source", destination) + except OSError: + pass + if os.path.realpath(chemin) == os.path.realpath(destination): + raise ErreurMoteur("destination_source", destination) + + +# Ce qu'une paire de crochets enferme dans une formule, sans les paires +# imbriquées : « [[#This Row],[Montant]] » rend « #This Row » et +# « Montant ». +_CROCHETS_FORMULE = re.compile(r"\[([^\[\]]*)\]") + + +def _litteraux_de_formule(formule, sans_egal=False): + """Ce qu'une formule PRÉSERVE, et que le filet doit donc excuser. + + Les guillemets ne suffisent pas : une référence structurée de tableau + porte le nom de colonne entre CROCHETS et sans guillemets — + `=T[[#This Row],[Montant]]`. Toutes les cellules d'une colonne + calculée partagent le MÊME texte, si bien que le bloc toléré ne + l'excuse qu'une fois là où la copie le porte deux fois : dans la + feuille et dans `xl/tables/`. Rendu en tolérance EXACTE, le nom cesse + de se compter. + + Une formule matricielle n'est pas une `str` mais un objet à `text` ; + la tester par `startswith` la faisait passer inaperçue. + + `sans_egal` pour un texte que l'appelant SAIT être une formule : OOXML + omet le « = » initial dans `calculatedColumnFormula`, et la garde le + rejetait donc — un tableau dont la colonne calculée n'a pas de formule + de cellule équivalente se faisait refuser. + """ + texte = ( + formule if isinstance(formule, str) else getattr(formule, "text", None) + ) + # La garde sur « = » est LOAD-BEARING : appelée sur toute cellule, la + # fonction tolérerait chaque valeur texte et le filet ne refuserait + # plus rien. + if not isinstance(texte, str) or not texte: + return () + if not sans_egal and not texte.startswith("="): + return () + morceaux = {texte} + morceaux.update(re.findall(r'"([^"]*)"', texte)) + for brut in _CROCHETS_FORMULE.findall(texte): + morceaux.add(brut) + # Excel échappe `[`, `]`, `#` et l'apostrophe d'un nom de colonne + # par une apostrophe : le nom réel est la forme déséchappée. + morceaux.add(brut.replace("'", "")) + return {m for m in morceaux if m} + + +def _valeurs_gardees(classeur, feuilles, format_lu): + """Ce que le moteur conserve SCIEMMENT, et qui a été annoncé. + + La vérification d'après écriture refuse tout ce qui subsiste ; cette + liste est la seule tolérance, et elle doit rester courte et justifiée. + Chaque entrée est conservée parce que la retirer casserait ce que la + règle de la formule vient de préserver. + """ + gardees = set() + for feuille in feuilles or (): + # `ligne[0]` n'est de la structure que là où il porte VRAIMENT un + # chemin de clé. La colonne 1 d'un tableau d'objets porte des + # VALEURS, et les tolérer aveuglait le filet sur la colonne même + # qu'il doit surveiller. + if feuille.colonne_structure != 1: + continue + for ligne in feuille.lignes: + if ligne and isinstance(ligne[0], str): + gardees.add(ligne[0]) + gardees.update(re.split(r"[.\[\]@#]", ligne[0])) + if classeur is None: + return gardees + for onglet in classeur.worksheets: + gardees.add(onglet.title) + gardees.update(onglet.defined_names.keys()) + for tableau in getattr(onglet, "tables", {}) or {}: + gardees.add(str(tableau)) + # Une colonne de tableau porte sa PROPRE formule, que rien ne lit + # par la grille : elle vit dans `xl/tables/` et la règle de la + # formule la préserve, donc ce qu'elle nomme doit être excusé. + for tableau in (getattr(onglet, "tables", {}) or {}).values(): + for tcol in getattr(tableau, "tableColumns", None) or (): + for attribut in ( + "calculatedColumnFormula", + "totalsRowFormula", + ): + gardees.update( + _litteraux_de_formule( + getattr(tcol, attribut, None), sans_egal=True + ) + ) + for ligne in onglet.iter_rows(): + for cellule in ligne: + # Le TEXTE d'une formule : la règle 1 interdit d'y toucher, + # et le rapport le compte et l'annonce. + gardees.update(_litteraux_de_formule(cellule.value)) + gardees.update(classeur.defined_names.keys()) + return gardees + + +def ecrire(chemin, destination, options): + """Écrire la copie. L'original n'est jamais modifié.""" + import random + + _refuser_si_source(chemin, destination) + format_lu, feuilles, classeur, rapport, options = _preparer( + chemin, options + ) + options["destination"] = destination + table = Correspondance.charger(options.get("table_chemin")) + prevus = _fichiers_prevus(chemin, feuilles, options, table) + _refuser_table_confondue( + chemin, options.get("table_chemin"), prevus + [destination] + ) + graine = options.get("graine") + rng = ( + random.Random(graine) if graine not in (None, "") else random.Random() + ) + hors_cellules = {} + if format_lu == "xlsx": + hors_cellules = nettoyer_hors_cellules(classeur, table, options) + + def appliquer(feuille, numero, index, neuve): + feuille.lignes[numero - 1][index - 1] = neuve + if feuille.source is not None: + feuille.source.cell(row=numero, column=index).value = neuve + # Hors tableur, l'ancre est la SEULE voie d'écriture : sans elle, + # le graveur reparcourait l'arbre et ignorerait la portée. + ancre = feuille.ancres.get((numero, index)) + if ancre is None: + return + conteneur, cle = ancre + if isinstance(conteneur, (dict, list)): + conteneur[cle] = neuve + elif cle is None: + conteneur.text = _garder_espaces(conteneur.text, neuve) + elif cle == "#tail": + conteneur.tail = _garder_espaces(conteneur.tail, neuve) + else: + conteneur.attrib[cle] = "" if neuve is None else str(neuve) + + hors_portee_gardees = {} + bilan = _parcourir( + feuilles, + options, + table, + rng, + appliquer=appliquer, + gardees_out=hors_portee_gardees, + ) + if not bilan["remplacees"] and not options.get("conversion"): + if options["colonnes_intactes"] or not ( + options["nombres"] or options["texte"] + ): + raise ErreurMoteur("tout_exclu", "") + raise ErreurMoteur("rien_a_faire", "") + + cible = options.get("conversion") or "" + if format_lu == "xlsx": + _resynchroniser_tableaux(classeur) + # Récolter les tolérances sur le classeur qui SERA enregistré : un + # littéral de formule ou un titre pris sur une feuille que la copie + # ne porte pas taisait le filet au nom d'une formule que le + # destinataire ne verra jamais. + if not (cible and cible != format_lu): + _retirer_feuilles_hors_portee(classeur, options) + gardees = _valeurs_gardees(classeur, feuilles, format_lu) + # Une cellule hors portée reste EN CLAIR par décision, et l'aperçu la + # nomme : sa valeur n'est pas une fuite. Seules comptent les feuilles + # qui partent dans la copie — celles que `_retirer_feuilles_hors_portee` + # supprime n'y sont plus, et une de leurs valeurs qui reparaîtrait dans + # un cache est une fuite comme une autre. + retenues = options.get("feuilles") + for nom, valeurs_vues in hors_portee_gardees.items(): + if retenues and nom is not None and nom not in retenues: + continue + gardees.update(valeurs_vues) + + if cible and cible != format_lu: + fichiers = convertir( + chemin, + destination, + cible, + options, + feuilles=feuilles, + table=table, + ) + elif format_lu == "xlsx": + _ecrire_atomique(destination, classeur.save) + fichiers = [destination] + elif format_lu == "csv": + fichiers = _ecrire_csv(destination, feuilles[0], options) + elif format_lu in ("json", "xml"): + fichiers = _ecrire_arbre(destination, feuilles[0], format_lu) + else: + # `.xls` et Access n'ont pas de graveur : la copie repart en .xlsx + fichiers = convertir( + chemin, + destination, + "xlsx", + options, + feuilles=feuilles, + table=table, + ) + + # Le filet : relire les OCTETS écrits. Il ne dépend d'aucune + # énumération de vecteurs, donc un endroit du format que personne n'a + # pensé à nettoyer produit un refus, là où une liste de parties à + # vérifier produirait un silence. + fuites, non_vues = verifier_copie(fichiers, table, gardees) + if fuites: + for fichier in fichiers: + if fichier and os.path.isfile(fichier): + os.unlink(fichier) + apercu = "; ".join( + f"{v!r} -> {', '.join(sorted(set(parties))[:2])}" + for v, parties in sorted(fuites.items())[:5] + ) + # Une macro cite couramment la valeur d'une cellule, et le projet + # VBA est recopié tel quel : garder les macros rend alors la copie + # irrecevable. Le refus est juste, mais l'option qui le cause vient + # d'être choisie une écran plus tôt, et le détail seul nomme une + # partie du format sans dire quoi en faire. + conseil = "" + if options.get("garder_macros") and any( + "vba" in partie.lower() or partie.lower().endswith(".bin") + for parties in fuites.values() + for partie in parties + ): + conseil = ( + "The kept VBA project quotes a source value;" + " answer no to the macro question to write the copy." + ) + raise ErreurMoteur( + "fuite_detectee", f"{len(fuites)} — {apercu}", conseil + ) + + chemin_table = options.get("table_chemin") + if chemin_table: + try: + # Les feuilles que l'opérateur a VRAIMENT changées, et + # celles-là seules. `entetes_par_feuille` porte AUSSI les + # empans mesurés — c'est ce qui a été montré à l'écran, et + # c'est là-dessus qu'il a consenti — mais les retenir + # imposerait la mesure d'un fichier à tout le lot, jusque là + # où la mesure du suivant dirait autre chose. Sans cette + # liste, une erreur de mesure devenait la loi du lot. + demandees = options.get("entetes_par_feuille") or {} + for nom in options.get("entetes_corrigees") or (): + if str(nom) not in demandees: + continue + table.entetes[str(nom)] = noyau.lignes_entieres( + demandees[str(nom)] + ) + table.ecrire(chemin_table) + except OSError: + # Une copie sans sa table reçoit les mêmes mots que le fichier + # suivant du lot, et deux clients fusionnent sur un seul mot. + for fichier in fichiers: + if fichier and os.path.isfile(fichier): + os.unlink(fichier) + raise + + bilan["fichiers"] = fichiers + bilan["hors_cellules"] = hors_cellules + _nommer_les_colonnes_ecartees(bilan, rapport, options) + bilan["avertissements"] = _avertissements(rapport, options) + bilan["table"] = chemin_table or "" + bilan["valeurs_non_verifiees"] = non_vues + return bilan + + +def _garder_espaces(brut, neuve): + """Réécrire un texte sans perdre son encadrement. + + Le texte d'un élément XML porte l'indentation du document et l'espace + qui sépare deux mots d'un contenu mixte. Écrire la valeur nue collait + « acai » et aplatissait le document. + """ + if neuve is None: + return "" + brut = brut or "" + tete = brut[: len(brut) - len(brut.lstrip())] + queue = brut[len(brut.rstrip()) :] + return f"{tete}{neuve}{queue}" + + +def _retirer_feuilles_hors_portee(classeur, options): + """Une feuille écartée de la portée ne part pas dans la copie. + + `classeur.save` grave le classeur ENTIER : répondre « Ventes » à la + question des feuilles laissait les autres — dont une feuille masquée — + intactes dans le fichier livré, avec pour seule trace « N cellules + laissées hors portée ». Les autres cibles les retirent déjà. + """ + # Une feuille graphique n'a AUCUNE cellule : la passe sur la grille ne + # la voit pas, `worksheets` l'exclut par construction, et son titre, + # ses titres d'axes, son en-tête et ses caches de série ne passent donc + # par aucune règle. Rien ne peut l'anonymiser — elle part, toujours, et + # l'avertissement le dit. La retirer seulement quand une sélection de + # feuilles existe faisait mentir cet avertissement dans tous les autres + # cas. + for onglet in list(getattr(classeur, "chartsheets", []) or []): + classeur.remove(onglet) + + retenues = options.get("feuilles") + if not retenues: + return + for nom in [ + onglet.title + for onglet in classeur.worksheets + if onglet.title not in retenues + ]: + del classeur[nom] + + +def _noter_gardee(gardees_out, feuille, valeur): + """Une valeur laissée intacte, notée sous ses DEUX formes. + + Le filet cherche la valeur telle que la table la porte, espaces + compris ; ne noter que la forme dépouillée laissait un libellé à + espace final annoncé remplacé ailleurs et jamais excusé ici — un + refus sur du travail légitime, sur un fichier ordinaire dont un + en-tête finit par une espace. + """ + texte = str(valeur_hors_tableur(valeur) or "") + for forme in (texte, texte.strip()): + if len(forme) >= noyau.LONGUEUR_VERIFIABLE: + gardees_out.setdefault(feuille, set()).add(forme) + + +def _resynchroniser_tableaux(classeur): + """Un tableau porte une COPIE du texte de sa cellule d'en-tête. + + OOXML exige que `tableColumn.name` égale la cellule d'en-tête. Après + l'anonymisation de celle-ci les deux divergent : rien n'est plus + préservé au bénéfice d'une formule, et le texte d'origine reste + simplement derrière. `totalsRowLabel` n'est jamais une cellule et + survivait quelles que soient les options. + + Appelé APRÈS la passe sur la grille, contrairement au nettoyage hors + cellules : il lui faut la valeur d'en-tête déjà remplacée. + """ + for onglet in classeur.worksheets: + for tableau in list((getattr(onglet, "tables", {}) or {}).values()): + debut = tableau.ref.split(":")[0] + colonne = onglet[debut].column + # La DERNIÈRE ligne d'en-tête porte les noms de champ. Prendre + # la première donnait, sur un en-tête de deux lignes, la ligne + # de CATÉGORIE — souvent la même valeur sur plusieurs colonnes, + # d'où des `tableColumn` de nom identique, qu'OOXML interdit : + # Excel annonce alors un fichier à réparer. + hauteur = getattr(tableau, "headerRowCount", 1) + ligne = onglet[debut].row + max(int(hauteur or 1), 1) - 1 + pris = set() + for rang, tcol in enumerate(tableau.tableColumns): + cellule = onglet.cell(row=ligne, column=colonne + rang) + nom = cellule.value + nom = nom if isinstance(nom, str) and nom.strip() else "" + # Un nom qu'AUCUNE cellule ne porte — en-tête vide, ou + # tableau déclaré sans ligne d'en-tête — restait tel quel : + # le texte d'origine sortait dans `xl/tables/`, et le filet + # ne pouvait pas le voir puisqu'il n'a jamais été lu d'une + # cellule, donc jamais annoncé remplacé. + if not nom or nom in pris: + nom = "colonne_%d" % (rang + 1) + pris.add(nom) + tcol.name = nom + if getattr(tcol, "totalsRowLabel", None): + tcol.totalsRowLabel = None + + +def _refuser_table_confondue(chemin, chemin_table, fichiers): + """La table ne peut être NI la source NI un fichier écrit. + + Elle est gravée APRÈS la copie, en écrasant sa cible : confondue avec + la source, elle détruit l'original ; confondue avec la copie, elle + laisse sous le nom de la copie la liste en clair de toutes les valeurs + réelles, et le menu annonce « Written: ». + """ + if not chemin_table: + return + reel = os.path.realpath(chemin_table) + if reel == os.path.realpath(chemin): + raise ErreurMoteur("table_source", chemin_table) + for fichier in fichiers: + if fichier and reel == os.path.realpath(fichier): + raise ErreurMoteur("table_source", chemin_table) + + +def _ecrire_arbre(destination, feuille, format_lu): + """Graver l'arbre JSON ou XML que les ancres viennent de modifier. + + Aucun second parcours : ce que `_parcourir` a décidé est déjà DANS + l'arbre, par les ancres. Un graveur qui reparcourait la source + ignorerait la portée, le plancher et les colonnes intactes. + """ + arbre = feuille.arbre + + def ecrivain(cible): + if format_lu == "json": + # Le porteur d'une racine scalaire : l'ancre a écrit DANS sa + # case, c'est elle qu'il faut sérialiser. Pour un dict ou une + # liste il vaut None, et `arbre` est déjà l'objet muté. + charge = arbre if feuille.porteur is None else feuille.porteur[0] + with open(cible, "w", encoding="utf-8") as fh: + json.dump(charge, fh, ensure_ascii=False, indent=1) + else: + arbre.write(cible, encoding="utf-8", xml_declaration=True) + + _ecrire_atomique(destination, ecrivain) + return [destination] + + +def _ecrire_csv(destination, feuille, options): + def ecrivain(cible): + with open(cible, "w", encoding="utf-8", newline="") as fh: + graveur = csv.writer( + fh, delimiter=options.get("delimiteur") or "," + ) + for ligne in feuille.lignes: + graveur.writerow( + [ + "" if v is None else valeur_hors_tableur(v) + for v in ligne + ] + ) + + _ecrire_atomique(destination, ecrivain) + return [destination] + + +def _transformer_json(noeud, options, table, rng): + """(valeur, compte). Les CLÉS sont de la structure et restent. + + `compte` porte, par famille, le nombre de FEUILLES réécrites. Il est + vide quand le sous-arbre revient identique : l'appelant en fait alors un + « intact ». Sans ce compte, un conteneur reconstruit à l'identique + valait une cellule remplacée — ce qui désarmait les refus « rien à + faire » et « tout exclu », et faisait écrire une copie identique à la + source en l'annonçant anonymisée. + """ + if isinstance(noeud, (dict, list)): + compte = {} + entrees = ( + noeud.items() if isinstance(noeud, dict) else enumerate(noeud) + ) + neuf = {} if isinstance(noeud, dict) else [None] * len(noeud) + for cle, valeur in entrees: + neuf[cle], partiel = _transformer_json(valeur, options, table, rng) + for fam, feuilles_reecrites in partiel.items(): + compte[fam] = compte.get(fam, 0) + feuilles_reecrites + return neuf, compte + neuve = anonymise_cellule(noeud, options, table, rng) + if neuve is _INTACTE: + return noeud, {} + return neuve, {classer(noeud): 1} + + +def _nom_de_feuille_anonyme(feuille, table, options): + """Le nom d'une feuille, remplacé, pour une CONVERSION. + + Une table Access ou une feuille `.xls` porte souvent le nom du client, + et la conversion l'écrit tel quel : en nom d'onglet, en clé de premier + niveau d'un JSON, en nom de fichier. Contrairement au classeur d'où + une formule le référence, ici RIEN ne le résout — c'est donc de la + donnée, et le tolérer aurait laissé le nom du client dans la copie + tout en faisant refuser le fichier au filet. + + Il passe par la MÊME table que les cellules : la feuille et les + valeurs qui la nomment reçoivent le même mot. + """ + from script.data.external_file import nouveau_mot + + if table is None or not feuille.nom: + return feuille.nom + return nouveau_mot(str(feuille.nom), table, options["vivier"]) + + +def convertir(chemin, destination, cible, options, feuilles=None, table=None): + """Écrire la copie dans un AUTRE format. Rend la liste des fichiers. + + Quand la cible ne peut pas porter la forme de la source — un classeur + de plusieurs feuilles vers csv, une base Access vers csv — la + destination est un RÉPERTOIRE, un fichier par feuille. Le menu le dit + avant d'écrire. + """ + if cible not in CIBLES_CONVERSION: + raise ErreurMoteur("format_inconnu", cible) + if feuilles is None: + feuilles = _preparer(chemin, options)[1] + retenues = [ + f + for f in feuilles + if not options.get("feuilles") or f.nom in options["feuilles"] + ] + if not retenues: + raise ErreurMoteur("aucune_feuille", "") + + noms = { + f.nom: _nom_de_feuille_anonyme(f, table, options) for f in retenues + } + if cible == "xlsx": + return _convertir_vers_xlsx(destination, retenues, noms) + if cible == "csv": + if len(retenues) == 1: + return _ecrire_csv(destination, retenues[0], options) + return _convertir_vers_repertoire(destination, retenues, options, noms) + if cible == "json": + return _convertir_vers_json(destination, retenues, noms) + return _convertir_vers_xml(destination, retenues) + + +def _convertir_vers_xlsx(destination, feuilles, noms=None): + from openpyxl import Workbook + + classeur = Workbook() + classeur.remove(classeur.active) + pris = set() + for feuille in feuilles: + brut = (noms or {}).get(feuille.nom, feuille.nom) + onglet = classeur.create_sheet(nom_de_fichier_sur(brut, pris, 31)) + for ligne in feuille.lignes: + onglet.append([_valeur_pour_xlsx(v) for v in ligne]) + _ecrire_atomique(destination, classeur.save) + return [destination] + + +def _valeur_pour_xlsx(valeur): + """La valeur telle qu'un classeur la porte, sans passer par du texte. + + `valeur_hors_tableur` est écrite pour csv, json et xml, trois formats + sans types : elle rend une date en chaîne ISO. openpyxl, lui, porte + nativement `datetime`, `int`, `float` et `bool` — convertir en texte + faisait perdre le type, et la copie portait du texte là où une date + était attendue, donc ne se réimportait plus comme telle. + """ + if valeur is None or isinstance(valeur, (bool, int, float, str)): + return valeur + texte = getattr(valeur, "text", None) + if isinstance(texte, str): + return texte + if isinstance(valeur, (bytes, bytearray)): + return None + if isinstance(valeur, (dict, list)): + # Un conteneur imbriqué d'un JSON : openpyxl lève sur une cellule + # qu'il ne sait pas porter, et l'erreur ressortait brute sous + # « format non reconnu ». Son contenu est déjà anonymisé. + return json.dumps(valeur, ensure_ascii=False) + return valeur + + +def _preparer_repertoire(destination): + if os.path.isdir(destination) and os.listdir(destination): + raise ErreurMoteur("repertoire_non_vide", destination) + os.makedirs(destination, mode=0o700, exist_ok=True) + + +def _convertir_vers_repertoire(destination, feuilles, options, noms=None): + _preparer_repertoire(destination) + pris = set() + ecrits = [] + for feuille in feuilles: + nom = nom_de_fichier_sur( + (noms or {}).get(feuille.nom, feuille.nom), pris + ) + cible = os.path.join(destination, f"{nom}.csv") + ecrits.extend(_ecrire_csv(cible, feuille, options)) + return ecrits + + +def _noms_neutres(feuille): + """Des noms de colonne quand la feuille n'a PAS d'en-tête. + + Sans eux, la première ligne de données prenait la place des noms de + clé : en clair dans la copie, et perdue comme donnée puisqu'un objet + ne porte pas sa clé deux fois. + """ + largeur = max((len(l) for l in feuille.lignes), default=0) + return ["colonne_%d" % (i + 1) for i in range(largeur)] + + +def _cles_distinctes(etiquettes): + """Une clé d'objet par colonne, toutes DISTINCTES. + + Un objet JSON écrase la clé qu'il répète : deux colonnes au même + en-tête, ou deux en-têtes vides, n'en laissaient qu'une dans la copie + et la donnée de l'autre disparaissait sans qu'une ligne ne le dise. + Un tableur laisse les deux faire — l'en-tête n'y est qu'une ligne. + + Une étiquette vide n'est pas None : elle porte la chaîne vide, que le + repli sur « cN » ne couvrait pas. Le test porte sur l'étiquette + dépouillée, la clé garde l'étiquette telle quelle. + """ + sortie = [] + pris = set() + for rang, valeur in enumerate(etiquettes, start=1): + nom = "" if valeur is None else str(valeur) + if not nom.strip(): + nom = "c%d" % rang + candidat, suffixe = nom, 2 + while candidat in pris: + candidat = "%s_%d" % (nom, suffixe) + suffixe += 1 + pris.add(candidat) + sortie.append(candidat) + return sortie + + +def _convertir_vers_json(destination, feuilles, noms=None): + sortie = {} + # La clé de premier niveau nomme la feuille, et passe par la même + # distinction que ses colonnes : les deux autres graveurs réservent + # déjà leurs noms d'onglet et de fichier, celui-ci écrasait. + cles = _cles_distinctes([(noms or {}).get(f.nom, f.nom) for f in feuilles]) + for cle, feuille in zip(cles, feuilles): + etiquettes = _cles_distinctes( + feuille.etiquettes or _noms_neutres(feuille) + ) + enregistrements = [] + for ligne in corps(feuille): + enregistrements.append( + { + etiquettes[i]: valeur_hors_tableur(v) + for i, v in enumerate(ligne) + if i < len(etiquettes) + } + ) + sortie[cle] = enregistrements + + def ecrivain(cible): + with open(cible, "w", encoding="utf-8") as fh: + json.dump(sortie, fh, ensure_ascii=False, indent=1) + + _ecrire_atomique(destination, ecrivain) + return [destination] + + +def _nom_de_balise_sur(etiquette, rang): + """Une étiquette rendue légale comme nom d'élément XML. + + XML interdit à un nom de commencer par un chiffre : une colonne + intitulée « 2024 » produisait `<2024>`, écrit sans broncher, annoncé + comme écrit, et refusé par tout analyseur — y compris celui du dépôt. + """ + brut = re.sub(r"[^A-Za-z0-9_.-]", "_", str(etiquette or "")).strip("_") + if not brut: + return f"c{rang}" + if not re.match(r"[A-Za-z_]", brut[0]): + return f"c{rang}_{brut}" + return brut + + +def _convertir_vers_xml(destination, feuilles): + if len(feuilles) > 1: + raise ErreurMoteur("format_inconnu", "xml") + feuille = feuilles[0] + racine = ET.Element("table") + etiquettes = [ + _nom_de_balise_sur(v, i) + for i, v in enumerate( + feuille.etiquettes or _noms_neutres(feuille), start=1 + ) + ] + for ligne in corps(feuille): + noeud = ET.SubElement(racine, "ligne") + for index, valeur in enumerate(ligne): + if index >= len(etiquettes): + continue + champ = ET.SubElement(noeud, etiquettes[index]) + rendu = valeur_hors_tableur(valeur) + champ.text = "" if rendu is None else str(rendu) + + def ecrivain(cible): + ET.ElementTree(racine).write( + cible, encoding="utf-8", xml_declaration=True + ) + + _ecrire_atomique(destination, ecrivain) + return [destination] diff --git a/script/todo/todo.py b/script/todo/todo.py index b32d254..d0c9eba 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -40,6 +40,7 @@ from script.todo.qemu_menu import QemuMenuMixin from script.todo.qemu_network import QemuNetworkMixin from script.todo.qemu_recover import QemuRecoverMixin 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.vpn_menu import VpnMenuMixin @@ -104,6 +105,7 @@ class TODO( QemuAccessMixin, ProxmoxMenuMixin, LongTestMenuMixin, + TransformMenuMixin, VpnMenuMixin, AssistantMenuMixin, ): @@ -235,22 +237,23 @@ class TODO( ── {t("Data")} ── [6] {t("Database - Database tools")} [7] {t("Analyse - Odoo database analysis")} +[8] {t("Transform data - Transform your data")} ── {t("Sources & documentation")} ── -[8] {t("Git - Git and shell tools")} -[9] {t("Doc - Documentation search")} +[9] {t("Git - Git and shell tools")} +[10] {t("Doc - Documentation search")} ── {t("AI & automation")} ── -[10] {t("GPT code - AI assistant tools")} -[11] {t("Automation - Demonstration of developed features")} +[11] {t("GPT code - AI assistant tools")} +[12] {t("Automation - Demonstration of developed features")} ── {t("Deployment, network & security")} ── -[12] {t("Deploy - Deploy ERPLibre locally")} -[13] {t("Network - Network tools")} -[14] {t("Security - Dependency security audit")} +[13] {t("Deploy - Deploy ERPLibre locally")} +[14] {t("Network - Network tools")} +[15] {t("Security - Dependency security audit")} ── {t("Preferences")} ── -[15] {t("Language - Change language / Changer la langue")} +[16] {t("Language - Change language / Changer la langue")} [0] {t("Back")} """ while True: @@ -287,34 +290,38 @@ class TODO( if status is not False: return elif status == "8": - status = self.prompt_execute_git() + status = self.prompt_execute_transform() if status is not False: return elif status == "9": - status = self.prompt_execute_doc() + status = self.prompt_execute_git() if status is not False: return elif status == "10": - status = self.prompt_execute_gpt_code() + status = self.prompt_execute_doc() if status is not False: return elif status == "11": - status = self.prompt_execute_function() + status = self.prompt_execute_gpt_code() if status is not False: return elif status == "12": - status = self.prompt_execute_deploy() + status = self.prompt_execute_function() if status is not False: return elif status == "13": - status = self.prompt_execute_network() + status = self.prompt_execute_deploy() if status is not False: return elif status == "14": - status = self.prompt_execute_security() + status = self.prompt_execute_network() if status is not False: return elif status == "15": + status = self.prompt_execute_security() + if status is not False: + return + elif status == "16": status = self._change_language() if status is not False: return @@ -584,6 +591,7 @@ class TODO( "prompt_execute_config": "Config", "prompt_execute_database": "Database", "prompt_execute_analyse": "Analyse", + "prompt_execute_transform": "Transform data", "prompt_execute_doc": "Doc", "prompt_execute_git": "Git", "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 » se tape par réflexe ; recopier un nom long oblige à regarder ce 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() if choix is None: - return + return False print() - if monitoring.run_analysis(analyse, database, extra=choix) == 2: - return + code = monitoring.run_analysis(analyse, database, extra=choix) + 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( f"⚠️ {t('This DESTROYS the data of')} '{database}'" @@ -4472,9 +4503,17 @@ class TODO( ).strip() if tape != database: print(f"↩️ {t('Cancelled: nothing was written.')}") - return - monitoring.run_analysis( - analyse, database, extra=choix + ["--apply", "--confirm", database] + return False + # SEUL le code de succès vaut « l'écriture a eu lieu » : un plan + # 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): @@ -4501,6 +4540,18 @@ class TODO( elif mode == "whitelist": print(f"❌ {t('A whitelist with no model would do nothing.')}") 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( f"💬 {t('Python file declaring MOTS (empty for the built-in): ')}" ).strip() @@ -4641,8 +4692,16 @@ class TODO( if neutralise != "n": more_arg = "--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"{more_arg}--ignore_cache --image {image}", return_status_and_output=True, @@ -4653,7 +4712,7 @@ class TODO( print(f"❌ {t('The restore failed.')}") return None if more_arg: - status, _ = self._execute.exec_command_live( + status, _ = self.execute.exec_command_live( f"./script/addons/update_prod_to_dev.sh {database}", return_status_and_output=True, single_source_erplibre=True, diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 406d2fb..8e6ec0d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -11410,6 +11410,10 @@ TRANSLATIONS = { "fr": "Annulé : rien n'a été écrit.", "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": { "fr": "Hybride : les modèles à données personnelles par défaut, ajustés", "en": "Hybrid: the default personal-data models, adjusted", @@ -11498,6 +11502,14 @@ TRANSLATIONS = { "fr": "Rien n'a été écrit :", "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:": { "fr": "Ce fichier ne déclare aucun MOTS :", "en": "This file declares no MOTS:", @@ -12831,6 +12843,764 @@ TRANSLATIONS = { # « models », « still waiting for » et « Type an address » servent aussi # ici et sont définies plus haut : les redéfinir écraserait la première # 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: ", + }, } diff --git a/script/todo/transform_form.py b/script/todo/transform_form.py new file mode 100644 index 0000000..e26767b --- /dev/null +++ b/script/todo/transform_form.py @@ -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"] diff --git a/script/todo/transform_menu.py b/script/todo/transform_menu.py new file mode 100644 index 0000000..3c4c86a --- /dev/null +++ b/script/todo/transform_menu.py @@ -0,0 +1,1413 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le menu « Transform data » : décrire un fichier externe, puis le copier. + +Le moteur vit dans `script/data/external_file.py` et tourne en +SOUS-PROCESSUS, sous l'interpréteur que `transform_setup.engine_python()` +désigne — le venv dédié pour Excel et Access, celui du CLI pour les +formats en pur stdlib. Même séparation que `lib_analyse` appelant `psql` +plutôt que d'importer psycopg2 : le CLI n'a pas à porter les +bibliothèques de lecture. + +L'ordre du dialogue est la règle, pas une préférence : rapport, questions, +puis MARCHE À BLANC, et l'écriture seulement après. C'est la convention du +dépôt pour l'action de menu qui écrit — « une question posée avant de +savoir ce qui sera touché n'est pas un consentement : c'est un pari ». + +Ce que ce mixin EMPRUNTE à `TODO` +--------------------------------- +`_is_yes`, `_is_yes_default_yes`, `fill_help_info`, `execute`, +`db_manager`, `_dir_path`, et — pour l'anonymisation d'une base — +`_monitoring_restore` et `_monitoring_write_flow`. La liste est écrite +ici parce qu'un emprunt tacite se casse en silence : une méthode +renommée dans `todo.py` ne lève qu'à l'exécution de l'entrée, et un +bouchon de test qui ne la fournit pas passe au vert sans rien éprouver. + +Les deux emprunts de `monitoring` sont VOULUS : `anonymize.py` sait déjà +remplacer les données d'une base, et `_monitoring_write_flow` sait déjà +montrer puis demander avec le nom retapé. Les réécrire ici ferait deux +dialogues à tenir d'accord pour la même chose. +""" + +from __future__ import annotations + +import datetime +import json +import os +import shlex +import shutil +import subprocess + +import click + +from script.todo import transform_setup +from script.todo.todo_i18n import t + +try: + from script.todo import todo_file_browser +except Exception: # pragma: no cover - urwid peut manquer + todo_file_browser = None + +MOTEUR = os.path.join("script", "data", "external_file.py") +SORTIE_PAR_DEFAUT = os.path.join("private", "transform") + +# Ce que chaque format peut porter en sortie. Les formats en lecture seule +# n'ont pas de graveur : leur copie repart en .xlsx. +EXTENSION_PAR_FORMAT = { + "xlsx": ".xlsx", + "xls": ".xlsx", + "xlsb": ".xlsx", + "access": ".xlsx", + "csv": ".csv", + "xml": ".xml", + "json": ".json", +} + +CIBLES = ("xlsx", "csv", "json", "xml") + +# Ce que le chemin COURT prend, quand l'opérateur refuse les options +# avancées. Sept questions dont chacune a 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. +# +# `calibre_chiffres` est le seul qui ne suit pas le défaut de son invite : +# le chemin court GARDE les largeurs. L'aperçu dit alors que l'étendue +# mesurée ne borne plus les nombres, ce qui est ce qui rend le raccourci +# honnête — un taux ou une année peut sortir de sa plage. +DEFAUTS_RAPIDES = { + "feuilles": [], + "colonnes_intactes": [], + "nombres": True, + "calibre_chiffres": True, + "texte": True, + "entetes": False, + "graine": "", +} + +# La famille d'une valeur laissée intacte, telle qu'on la nomme à l'écran. +# Une date et un booléen sont de VRAIES valeurs du client : les taire dans +# le bilan laissait croire à une copie entièrement remplacée. +_LIBELLE_INTACTES = { + "formule": "formula(s)", + "date": "date(s)", + "booleen": "boolean(s)", + "erreur": "error value(s)", + "binaire": "binary value(s)", + "texte": "text", + "nombre": "numeric", +} + + +class TransformMenuMixin: + # ------------------------------------------------------------------ + # Menu + # ------------------------------------------------------------------ + def prompt_execute_transform(self): + print(f"🪄 {t('Transform your data: read, describe, then copy.')}") + choices = [ + # « Source » et non « Source file » : l'entrée couvre + # désormais un fichier ET une base Odoo, et un libellé qui + # dit « fichier » ferait chercher ailleurs l'anonymiseur de + # base. + {"section": t("Source")}, + {"prompt_description": t("Open a file and read its report")}, + { + "prompt_description": t( + "Anonymise an Odoo database or a backup" + ) + }, + {"section": t("Environment")}, + {"prompt_description": t("Install the reading environment")}, + {"prompt_description": t("What can this machine read?")}, + {"prompt_description": t("Copies produced")}, + {"prompt_description": t("Databases produced")}, + ] + help_info = self.fill_help_info(choices) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._transform_open_and_report() + elif status == "2": + self._transform_anonymise_base() + elif status == "3": + self._transform_install_env() + elif status == "4": + self._transform_capabilities() + elif status == "5": + self._transform_copies() + elif status == "6": + self._transform_bases_produites() + else: + print(t("Command not found !")) + + # ------------------------------------------------------------------ + # Le moteur, en sous-processus + # ------------------------------------------------------------------ + def _transform_run(self, arguments, fmt=None): + """Lancer le moteur. Rend l'objet JSON, ou None sur erreur. + + stdout ne porte qu'un objet JSON ; stderr porte la progression et + les traces. Un stdout vide ou inanalysable est traité comme une + erreur — et non relayé en exception — pour que le menu affiche les + dernières lignes de stderr plutôt que de tomber à son tour. + """ + interpreteur = transform_setup.engine_python(fmt) + racine = transform_setup.racine() + environnement = dict(os.environ) + environnement["PYTHONPATH"] = racine + commande = [interpreteur, os.path.join(racine, MOTEUR)] + list( + arguments + ) + try: + acheve = subprocess.run( + commande, + capture_output=True, + text=True, + cwd=racine, + env=environnement, + ) + except (OSError, subprocess.SubprocessError) as exc: + print(f"❌ {exc}") + return None + + for ligne in (acheve.stderr or "").splitlines(): + if ligne.startswith("# "): + print(f" {ligne[2:]}") + + try: + resultat = json.loads(acheve.stdout or "") + except (ValueError, TypeError): + print(f"❌ {t('Command not found !')}") + print(f" {shlex.join(commande)}") + for ligne in (acheve.stderr or "").splitlines()[-20:]: + print(f" {ligne}") + return None + + if "erreur" in resultat: + print(f"❌ {t(resultat['erreur'])}{resultat.get('detail', '')}") + if resultat.get("conseil"): + print(f" → {t(resultat['conseil'])}") + return None + return resultat + + # ------------------------------------------------------------------ + # Le fichier d'entrée + # ------------------------------------------------------------------ + def _on_dir_selected(self, chemin): + """Le chemin choisi dans le navigateur, mis de côté. + + Ce mixin fournit SON rappel au lieu d'emprunter celui de sa classe + d'accueil : `TODO` nomme le sien `on_dir_selected` et pose + `dir_path`, tandis que le préfixé vit sur le gestionnaire de bases, + qui n'est pas un mixin mais un objet à part. Emprunter faisait + lever `AttributeError` à l'ouverture du navigateur — donc au + premier usage de l'entrée, et sur la première ligne du menu. + + Le navigateur sort de sa boucle lui-même après avoir appelé le + rappel : celui-ci n'a que le chemin à retenir. + """ + self._dir_path = chemin + + def _transform_select_file(self): + """Le parcours d'abord, la saisie en repli. + + La garde d'import est celle de `database_manager` : urwid peut + manquer, et sans elle « p » lèverait sur None. + """ + depart = os.path.join(os.getcwd(), SORTIE_PAR_DEFAUT) + if not os.path.isdir(depart): + depart = os.getcwd() + if todo_file_browser is not None: + self._dir_path = "" + navigateur = todo_file_browser.FileBrowser( + depart, self._on_dir_selected + ) + navigateur.run_main_frame() + if self._dir_path and os.path.isfile(self._dir_path): + print(self._dir_path) + return self._dir_path + reponse = input( + t("Enter to accept, p to browse, or type a path: ") + ).strip() + if not reponse or reponse == "0": + return None + chemin = os.path.expanduser(reponse) + if not os.path.isfile(chemin): + print(f"❌ {t('Not an ordinary file.')} {chemin}") + return None + return chemin + + # ------------------------------------------------------------------ + # Le rapport + # ------------------------------------------------------------------ + def _transform_render_report(self, rapport): + lignes = [] + nom = os.path.basename(rapport.get("chemin", "")) + taille = rapport.get("taille", 0) + lignes.append(f"🪄 {nom} — {taille} o — {rapport.get('format', '')}") + if rapport.get("divergence"): + lignes.append( + f" ⚠ {t('Contents do not match the extension: read as ')}" + f"{rapport.get('format', '')}" + ) + if rapport.get("encodage"): + lignes.append( + f" {t('Encoding detected by ')}" + f"{t(rapport['encodage_source'])}: {rapport['encodage']}" + ) + lignes.append( + f" {t('Delimiter detected by ')}" + f"{t(rapport['delimiteur_source'])}:" + f" {rapport['delimiteur']!r}" + ) + + feuilles = rapport.get("feuilles") or [] + if feuilles: + lignes.append("") + lignes.append(f" {len(feuilles)} {t('sheet(s)')}") + for feuille in feuilles: + marque = f" ({t('hidden')})" if feuille.get("masquee") else "" + lignes.append( + f" {feuille['nom']}{marque}" + f" {feuille['lignes']} × {feuille['colonnes_n']}" + f" {feuille['formules']} {t('formula(s)')}" + ) + if feuille.get("formules_litteral"): + lignes.append( + f" ⚠ {feuille['formules_litteral']}" + f" {t('formula(s) holding a text literal')}" + ) + for colonne in feuille.get("colonnes", [])[:12]: + borne = "" + if colonne.get("min") is not None: + borne = f" {colonne['min']}..{colonne['max']}" + plancher = " ⛔" if colonne.get("plancher") else "" + lignes.append( + f" {colonne['index']:>3} " + f"{str(colonne['etiquette'] or ''):<22}" + f"{colonne['type']:<9}" + f"{colonne['remplies']:>6} {t('row(s)')}" + f"{colonne['distinctes']:>6}" + f" {t('distinct value(s)')}{borne}{plancher}" + ) + reste = len(feuille.get("colonnes", [])) - 12 + if reste > 0: + lignes.append(f" … {reste}") + # Les colonnes au-delà du plafond des exemples n'en portent + # pas : une colonne sans exemple se lit comme une colonne + # vide, et le taire faisait juger une colonne sur rien. + manquants = feuille.get("exemples_manquants") or 0 + if manquants: + lignes.append( + f" ⚠ {manquants} {t('column(s) without examples')}" + ) + + requetes = rapport.get("requetes") or [] + if requetes: + # Nommées, non comptées : une base dont le travail vit dans + # ses requêtes se transmet amputée, et un chiffre ne dit pas + # ce qui manque. + lignes.append("") + lignes.append(f" {len(requetes)} {t('saved query(ies)')}") + for nom in requetes[:12]: + lignes.append(f" {nom}") + if len(requetes) > 12: + lignes.append( + f" … {len(requetes) - 12} {t('more, not listed')}" + ) + + hors = rapport.get("hors_cellules") or {} + if hors: + lignes.append("") + lignes.append(f" {t('Text outside cells')}") + if hors.get("macros"): + lignes.append(f" ⚠ {t('VBA macros present')}") + elif rapport.get("format") == "xlsx": + lignes.append(f" {t('No VBA macro')}") + for cle, libelle in ( + ("croises", "pivot table(s)"), + ("graphiques", "chart(s) with cached series"), + ("feuilles_graphiques", "chart sheet(s)"), + ("images", "image(s) and drawing(s)"), + ("liens_externes", "external link(s)"), + ("hyperliens", "cell hyperlink(s)"), + ("commentaires", "comment(s)"), + ("auteurs_commentaires", "comment author(s)"), + ("entetes_pieds", "header(s)/footer(s)"), + ("validations", "validation(s)"), + ("conditionnelles", "conditional format(s)"), + ): + if hors.get(cle): + lignes.append(f" {hors[cle]} {t(libelle)}") + if hors.get("createur") or hors.get("modifie_par"): + lignes.append( + f" {t('Author')}: {hors.get('createur') or ''}" + f" / {hors.get('modifie_par') or ''}" + ) + if hors.get("cibles_liens"): + lignes.append( + f" {t('Link targets')}:" + f" {', '.join(hors['cibles_liens'][:5])}" + ) + if hors.get("plages_nommees"): + lignes.append( + f" {t('named range(s)')}:" + f" {', '.join(hors['plages_nommees'][:8])}" + ) + + comptes = rapport.get("comptes") or {} + lignes.append("") + lignes.append( + f" {t('Anonymisable')}:" + f" {comptes.get('texte', 0)} {t('text')}," + f" {comptes.get('nombre', 0)} {t('numeric')}" + ) + lignes.append( + f" {t('word(s) in the pool')}: {rapport.get('vivier', 0)}" + ) + intacts = [] + for cle, libelle in ( + ("formule", "formula(s)"), + ("date", "date(s)"), + ("booleen", "boolean(s)"), + ("erreur", "error value(s)"), + ("binaire", "binary value(s)"), + ): + if comptes.get(cle): + intacts.append(f"{comptes[cle]} {t(libelle)}") + if intacts: + lignes.append(f" {t('Left intact')}: {', '.join(intacts)}") + for avertissement in rapport.get("avertissements", []): + lignes.append(f" ⚠ {t(avertissement)}") + return "\n".join(lignes) + + # ------------------------------------------------------------------ + # Les questions + # ------------------------------------------------------------------ + @staticmethod + def _transform_ask(libelle): + """La réponse, ou None quand l'opérateur tape « 0 ». + + Une sortie doit exister à CHAQUE question : s'apercevoir à la + onzième qu'on a ouvert le mauvais fichier ne doit pas obliger à + répondre à tout puis à refuser un nom de fichier. + """ + reponse = input(libelle).strip() + return None if reponse == "0" else reponse + + def _transform_ask_options(self, rapport): + """Les réponses, ou None si l'opérateur renonce.""" + print(f" {t('0 to cancel')}") + options = {} + reponse = self._transform_ask(t("Anonymise this file? (y/N): ")) + if reponse is None or not self._is_yes(reponse): + return None + + cible = self._transform_ask( + t("Convert at the same time? (xlsx/csv/json/xml, empty = keep): ") + ) + if cible is None: + return None + cible = cible.strip().lower() + if cible and cible not in CIBLES: + print(f"❌ {t('Format not recognised: ')}{cible}") + return None + options["conversion"] = cible + + # La table AVANT l'écran : elle dit si ce fichier appartient à + # un lot déjà entamé, et l'écran montre alors les lignes + # d'en-tête qu'un fichier précédent a fait corriger. La poser + # après l'écran laissait cette mémoire hors de vue. + reponse = self._transform_ask( + t("Mapping table to reuse (empty = a new one): ") + ) + if reponse is None: + return None + options["table_chemin"] = ( + os.path.expanduser(reponse) if reponse else "" + ) + + # L'écran AVANT les questions de portée : c'est lui qui répond + # aux deux qu'une invite ne sait pas poser — quelles lignes + # nomment les colonnes, et lesquelles rester intactes. Il rend + # `{}` pour dire « pose-moi les questions », ce qui laisse la + # suite se dérouler comme avant. + ecran = self._transform_ecran(rapport, options.get("table_chemin")) + if ecran is None: + return None + options.update(ecran) + + reponse = self._transform_ask(t("Advanced options? (y/N): ")) + if reponse is None: + return None + if not self._is_yes(reponse): + # L'écran a pu répondre par feuille : ses réponses passent + # AVANT les défauts, sans quoi refuser les options avancées + # effacerait ce qu'on vient de cocher. + for cle, valeur in DEFAUTS_RAPIDES.items(): + options.setdefault(cle, valeur) + if "colonnes_intactes_index_par_feuille" in options: + options["colonnes_intactes"] = [] + self._transform_dire_les_defauts(options) + return self._transform_ask_hors_cellules(rapport, options) + + noms = [f["nom"] for f in rapport.get("feuilles") or []] + if len(noms) > 1: + reponse = self._transform_ask( + t("Sheets to process (empty = all): ") + ) + if reponse is None: + return None + demandees = [n.strip() for n in reponse.split(",") if n.strip()] + resolues = [] + for demandee in demandees: + trouve = next( + (n for n in noms if n.strip().lower() == demandee.lower()), + None, + ) + if trouve is None: + manque = t( + "The selection matches no sheet;" + " nothing was written." + ) + print(f"❌ {manque} {demandee}") + return None + resolues.append(trouve) + options["feuilles"] = resolues + if resolues: + print(f" {', '.join(resolues)}") + + if "colonnes_intactes_index_par_feuille" in options: + # L'écran a répondu par feuille, ce que cette invite ne sait + # pas faire : la reposer inviterait à une réponse GLOBALE qui + # gèlerait la même colonne sur toutes les feuilles. + options["colonnes_intactes"] = [] + else: + reponse = self._transform_ask( + t("Columns to leave untouched (empty = none): ") + ) + if reponse is None: + return None + options["colonnes_intactes"] = [ + c.strip() for c in reponse.split(",") if c.strip() + ] + + reponse = self._transform_ask(t("Replace numbers? (Y/n): ")) + if reponse is None: + return None + options["nombres"] = self._is_yes_default_yes(reponse) + if options["nombres"]: + # Posée SOUS la question des nombres : sans eux elle n'a pas + # d'objet, et une invite qui ne change rien apprend à passer + # les invites. + reponse = self._transform_ask(t("Keep the digit count? (y/N): ")) + if reponse is None: + return None + options["calibre_chiffres"] = self._is_yes(reponse) + + reponse = self._transform_ask(t("Replace text? (Y/n): ")) + if reponse is None: + return None + options["texte"] = self._is_yes_default_yes(reponse) + + reponse = self._transform_ask( + t("Anonymise the header row too? (y/N): ") + ) + if reponse is None: + return None + options["entetes"] = self._is_yes(reponse) + + reponse = self._transform_ask( + t("Random seed (empty = not reproducible): ") + ) + if reponse is None: + return None + options["graine"] = reponse + + return self._transform_ask_hors_cellules(rapport, options) + + def _transform_ask_hors_cellules(self, rapport, options): + """Ce qu'un classeur porte HORS des cellules : macros, graphiques. + + Ces deux-là ne sont pas des options avancées et restent posées + dans les deux chemins : elles ne se voient pas dans les cellules, + elles ne se devinent pas, et la réponse change ce que la copie + porte. Elles ne se posent que si le fichier en a et que la copie + peut les porter — une question dont la réponse ne change rien + apprend à passer les questions. + """ + hors = rapport.get("hors_cellules") or {} + garde_xlsm = (options["conversion"] or rapport["format"]) == "xlsx" + if hors.get("macros") and garde_xlsm: + reponse = self._transform_ask( + t("Keep the VBA macros in the copy? (y/N): ") + ) + if reponse is None: + return None + options["garder_macros"] = self._is_yes(reponse) + if hors.get("graphiques") and garde_xlsm: + reponse = self._transform_ask( + t("Keep the charts in the copy? (y/N): ") + ) + if reponse is None: + return None + options["garder_graphiques"] = self._is_yes(reponse) + return options + + @staticmethod + def _transform_dire_les_defauts(options): + """Montrer ce que le chemin court a pris, sans le demander. + + Un raccourci qui ne dit pas ce qu'il choisit n'est pas un + raccourci mais une surprise : l'opérateur consent à ce qu'il LIT. + + Des PHRASES et non un tableau de oui/non : ce sont des décisions + prises, pas des questions posées, et « Remplacer les nombres → + oui » se lit comme une invite dont on aurait perdu la réponse. + """ + # L'écran a pu geler des colonnes, et le chemin court les GARDE : + # annoncer « aucune colonne laissée intacte » dirait alors le + # contraire de ce qui s'applique, sur le seul écran où + # l'opérateur consent. + gelees = options.get("colonnes_intactes_index_par_feuille") or {} + combien = sum(len(rangs) for rangs in gelees.values()) + perimetre = ( + f"{combien} {t('column(s) left untouched, chosen on screen')}" + if combien + else t("no column left untouched") + ) + # Un empan VIDE veut dire « cette feuille n'a pas d'en-tête », et + # l'écran y mène d'une frappe. Sa première ligne est alors de la + # donnée et se fait remplacer : annoncer l'en-tête protégé dirait + # le contraire de ce qui s'applique. Une feuille ABSENTE de la + # table veut dire « mesure-la », ce qui laisse l'en-tête en place. + empans = options.get("entetes_par_feuille") or {} + sans_entete = [nom for nom, rangs in empans.items() if not rangs] + entete = ( + f"{len(sans_entete)} {t('sheet(s) with no header row on screen')}" + if sans_entete + else t("the header row is left readable") + ) + print(f" {t('Taken as given:')}") + for ligne in ( + t("every sheet"), + perimetre, + t( + "numbers replaced, widths kept" + if options.get("calibre_chiffres") + else "numbers replaced within the measured extent" + ), + t("text replaced"), + entete, + t("no seed: not reproducible"), + ): + print(f" {ligne}") + + def _transform_ecran(self, rapport, table_chemin=""): + """Le périmètre choisi à l'écran, ou {} pour les invites. + + Trois issues, et non deux : la spec porte le périmètre, `{}` + demande les invites, et None annule. Confondre les deux dernières + supprimerait le repli en silence. + """ + if not (rapport.get("feuilles") or []): + return {} + reponse = self._transform_ask(t("Open the TUI for setup? (Y/n): ")) + if reponse is None: + return None + if not self._is_yes_default_yes(reponse): + return {} + try: + from script.todo import transform_form + except ImportError: # pragma: no cover - textual peut manquer + print( + " ⚠ %s" + % t("The screen needs textual; falling back to prompts.") + ) + return {} + try: + spec = transform_form.run_transform_form( + transform_form.contexte_depuis_rapport( + rapport, self._transform_memoire(table_chemin) + ) + ) + except Exception as exc: # pragma: no cover - pas de terminal + # Un écran qui ne peut pas s'ouvrir ne doit pas emporter le + # travail : les invites savent tout demander. + print(f" ⚠ {exc}") + return {} + if spec is None: + return None + return spec + + @staticmethod + def _transform_memoire(table_chemin): + """Les lignes d'en-tête qu'une table de lot se rappelle. + + Lue ICI et non par le moteur : l'écran vit dans le processus du + menu, et la table est un simple JSON. Une table absente, illisible + ou d'une version antérieure rend un dictionnaire vide — la mémoire + est un confort, jamais une condition pour ouvrir l'écran. + """ + if not table_chemin or not os.path.isfile(table_chemin): + return {} + try: + with open(table_chemin, "r", encoding="utf-8") as flux: + brut = json.load(flux) + # Une racine qui n'est pas un dictionnaire — un autre fichier + # de `private/` désigné à l'invite — lèverait `AttributeError` + # sur `.get`, hors de ce que la garde attrape, et emporterait + # l'ouverture de l'écran. Le moteur refusera ce chemin en + # NOMMANT la table ; ici la mémoire est un confort, et son + # absence n'a rien à empêcher. + if not isinstance(brut, dict): + return {} + return brut.get("entetes") or {} + except (OSError, ValueError): + return {} + + # ------------------------------------------------------------------ + # L'aperçu + # ------------------------------------------------------------------ + def _transform_preview(self, apercu): + """Montrer, PUIS demander. False si l'opérateur refuse.""" + print(f"\n {t('Preview — nothing written yet')}") + print( + f" {apercu.get('remplacees', 0)} {t('cell(s) replaced')}" + f" — {apercu.get('texte', 0)} {t('text')}," + f" {apercu.get('nombre', 0)} {t('numeric')}" + ) + if apercu.get("hors_portee"): + print( + f" {apercu['hors_portee']}" + f" {t('cell(s) left out of scope')}" + ) + ecartees = apercu.get("colonnes_ecartees") or [] + if ecartees: + noms = ", ".join( + sorted( + {str(c["etiquette"]) for c in ecartees if c["etiquette"]} + ) + ) + print( + f" {t('column(s) left alone: they hold identifiers')}:" + f" {noms}" + ) + intactes = apercu.get("intactes") or {} + if intactes: + # Une date, un booléen, une valeur d'erreur traversent par + # RÈGLE, non par oubli — et ce sont de vraies valeurs du + # client. Ne les compter nulle part faisait signer un + # consentement sur un fichier dont une colonne entière part + # en clair sans que rien ne le dise. + detail = ", ".join( + f"{compte} {t(_LIBELLE_INTACTES.get(famille, famille))}" + for famille, compte in sorted(intactes.items()) + if compte + ) + print(f" ⚠ {t('Left intact')} : {detail}") + # Le compte par famille dit POURQUOI, non OÙ : sur quarante + # colonnes, « 7 date(s) laissées » ne dit pas laquelle sort. Le + # critère est le même quelle que soit la raison — une colonne qui + # porte quelque chose et dont rien n'a été remplacé part entière. + # Celles qu'une réponse ou le plancher expliquent sont déjà + # nommées au-dessus, et n'y reviennent pas. + en_clair = apercu.get("colonnes_en_clair") or [] + if en_clair: + noms = ", ".join( + "%s/%s" + % ( + colonne["feuille"], + colonne["etiquette"] or "#%s" % colonne["index"], + ) + for colonne in en_clair + ) + print(f" ⚠ {t('column(s) entirely in clear')} : {noms}") + gardee = apercu.get("entete_gardee") or [] + if gardee: + # L'empan est MESURÉ, et il porte plusieurs lignes sur un + # export mis en page : un compteur ne dirait pas qu'un nom est + # dedans. Chaque valeur se montre donc, avec sa coordonnée. + print(f" ⚠ {t('These cells are copied verbatim:')}") + for cellule in gardee: + print(f" {cellule['cellule']} {cellule['valeur']!r}") + # Le plafond borne la LISTE, pas le compte : le taire faisait + # consentir sur un extrait pris pour le tout. + for nom, omises in sorted( + (apercu.get("entete_gardee_omises") or {}).items() + ): + print(f" {nom} : {omises} {t('more, not listed')}") + print(" " + t("Correct the header rows to anonymise them.")) + for exemple in apercu.get("apercu") or []: + print( + f" {exemple['cellule']}" + f" {exemple['avant']!r} → {exemple['apres']!r}" + ) + for fichier in apercu.get("fichiers") or []: + if fichier: + print(f" → {fichier}") + for avertissement in apercu.get("avertissements") or []: + print(f" ⚠ {t(avertissement)}") + return self._is_yes_default_yes(input(t("Write? (Y/n): "))) + + # ------------------------------------------------------------------ + # La destination + # ------------------------------------------------------------------ + def _transform_select_destination(self, source, fmt, macros=False): + """Le chemin de sortie, ou None si l'opérateur renonce. + + Le défaut NE REPREND PAS le nom source : sur un export réel, ce nom + porte le client, la base ou l'année, et toute l'entrée existe pour + produire un fichier transmissible. Le nom source reste offert, et + l'invite dit alors qu'il n'a pas été anonymisé. + + Un classeur qui GARDE ses macros se nomme `.xlsm` : Excel lie + l'extension au contenu, et refuse d'ouvrir un `.xlsx` qui porte un + projet VBA. La copie était écrite correctement et n'ouvrait pas. + """ + horodatage = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + extension = EXTENSION_PAR_FORMAT.get(fmt, ".out") + if macros and extension == ".xlsx": + extension = ".xlsm" + defaut = os.path.join( + SORTIE_PAR_DEFAUT, f"{horodatage}.anon{extension}" + ) + print(f"\n {t('Destination')}: {defaut}") + if extension == ".xlsm": + print( + " ⚠ %s" + % t( + "Macros are kept, so the copy is named .xlsm:" + " Excel refuses a .xlsx holding a VBA project." + ) + ) + avis = t( + "The file name is not anonymised;" + " the default does not reuse it." + ) + print(f" ⚠ {avis}") + reponse = self._transform_ask( + t("Enter to accept, p to browse, or type a path: ") + ) + if reponse is None: + return None + if not reponse: + return defaut + if reponse.lower() == "p": + if todo_file_browser is None: + print(f" {defaut}") + return defaut + depart = os.path.dirname(os.path.abspath(defaut)) + if not os.path.isdir(depart): + depart = os.getcwd() + self._dir_path = "" + navigateur = todo_file_browser.FileBrowser( + depart, self._on_dir_selected, open_dir=True + ) + navigateur.run_main_frame() + if not self._dir_path: + return None + choisi = os.path.join(self._dir_path, os.path.basename(defaut)) + print(f" {choisi}") + return choisi + return os.path.expanduser(reponse) + + @staticmethod + def _transform_table_par_defaut(destination): + """Le chemin de la table, TOUJOURS sous private/transform/. + + La table porte chaque valeur d'origine en clair : elle + ré-identifie la copie à elle seule. La poser à côté du fichier à + transmettre — ce que faisait le défaut, puisque la destination est + justement choisie hors de private/ — fait partir la clé avec le + chiffré au premier `zip -r` ou `scp -r` du dossier de livraison. + + Le nom DÉRIVE de la destination, ce qui reste le plus utile pour + retrouver la table d'une copie une semaine plus tard. Mais il ne + tient qu'au nom de BASE : deux livraisons rangées par année, + « 2026/export.xlsx » et « 2027/export.xlsx », donnaient le même + chemin. L'invite promet « vide = nouvelle » et le moteur chargeait + la table de la livraison précédente — ses mots, ses nombres, ses + empans — sans que rien ne signale sa présence, l'écran ayant déjà + été bâti sur une mémoire vide. + + Un chemin déjà pris prend donc un suffixe frais, et l'appelant le + DIT. Le partage voulu à l'intérieur d'un lot passe par l'invite, + où l'opérateur tape le chemin de la table précédente. + """ + chemin = TransformMenuMixin._transform_table_derivee(destination) + if not os.path.exists(chemin): + return chemin + horodatage = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + racine = chemin[: -len(".table.json")] + return f"{racine}-{horodatage}.table.json" + + @staticmethod + def _transform_table_derivee(destination): + """Le nom que la destination donne à sa table, SANS voir le disque. + + Séparé pour que l'appelant sache si le chemin retenu est bien + celui-là — comparer deux chemins vaut mieux que renifler un tiret + dans un nom de base, qui en porte un dès que la destination garde + l'horodatage du défaut. + """ + base = os.path.basename(os.path.splitext(destination)[0]) + return os.path.join(SORTIE_PAR_DEFAUT, f"{base}.table.json") + + def _transform_confirm_overwrite(self, cibles): + """Le nom tapé en entier, comme le reste du dépôt l'exige.""" + existants = [c for c in cibles if c and os.path.exists(c)] + if not existants: + return True + print(f" {t('These files would be overwritten: ')}") + for cible in existants: + print(f" {cible}") + attendu = os.path.basename(existants[0]) + reponse = input( + t( + "This file already exists. Type its name in full to" + " overwrite: " + ) + ).strip() + if reponse != attendu: + print(f"❌ {t('Name does not match, nothing was written.')}") + return False + return True + + # ------------------------------------------------------------------ + # Le déroulé complet + # ------------------------------------------------------------------ + def _transform_open_and_report(self): + chemin = self._transform_select_file() + if not chemin: + return + + format_devine = self._transform_format(chemin) + if not transform_setup.ensure(format_devine): + return + + rapport = self._transform_run(["--report", chemin], format_devine) + if rapport is None: + return + print(self._transform_render_report(rapport)) + + if rapport.get("arret"): + print(f"\n❌ {t(rapport['arret'])}") + return + + options = self._transform_ask_options(rapport) + if options is None: + print(t("Nothing to do.")) + return + + sortie_fmt = options["conversion"] or rapport["format"] + fmt_moteur = self._transform_fmt_moteur(rapport["format"], sortie_fmt) + destination = self._transform_select_destination( + chemin, sortie_fmt, options.get("garder_macros") + ) + if not destination: + print(t("Nothing to do.")) + return + options["destination"] = destination + if not options.get("table_chemin"): + options["table_chemin"] = self._transform_table_par_defaut( + destination + ) + # « vide = nouvelle » est une promesse : le dire quand le nom + # dérivé était déjà pris, plutôt que d'adopter en silence la + # table d'une autre livraison. + if options["table_chemin"] != self._transform_table_derivee( + destination + ): + print( + f" {t('A table of that name exists; using a new one.')}" + ) + print(f" {t('Mapping table: ')}{options['table_chemin']}") + + arguments = [ + "--plan", + chemin, + "--options", + json.dumps(options, ensure_ascii=False), + ] + if options.get("table_chemin"): + arguments += ["--table", options["table_chemin"]] + apercu = self._transform_run(arguments, fmt_moteur) + if apercu is None: + return + if not self._transform_preview(apercu): + print(t("Nothing to do.")) + return + + if not self._transform_confirm_overwrite( + apercu.get("fichiers") or [destination] + ): + return + + arguments = [ + "--apply", + chemin, + "--out", + destination, + "--options", + json.dumps(options, ensure_ascii=False), + ] + if options.get("table_chemin"): + arguments += ["--table", options["table_chemin"]] + bilan = self._transform_run(arguments, fmt_moteur) + if bilan is None: + return + self._transform_render_bilan(bilan, destination) + + @staticmethod + def _transform_fmt_moteur(source_fmt, sortie_fmt): + """Le format qui DÉCIDE de l'interpréteur du moteur. + + Le processus doit lire la source ET écrire la cible : il lui faut + l'union des bibliothèques. La source seule laissait csv→xlsx + importer openpyxl sous l'interpréteur du CLI, qui ne l'a pas ; la + cible seule enverrait un classeur au même interpréteur, qui ne sait + pas le lire. + """ + if source_fmt not in transform_setup.FORMATS_STDLIB: + return source_fmt + return sortie_fmt + + def _transform_render_bilan(self, bilan, destination): + for fichier in bilan.get("fichiers") or [destination]: + print(f"✅ {t('Written: ')}{fichier}") + print( + f" {bilan.get('remplacees', 0)} {t('cell(s) replaced')}" + f" — {bilan.get('texte', 0)} {t('text')}," + f" {bilan.get('nombre', 0)} {t('numeric')}" + ) + intactes = bilan.get("intactes") or {} + if intactes: + detail = ", ".join( + f"{compte} {t(_LIBELLE_INTACTES.get(famille, famille))}" + for famille, compte in sorted(intactes.items()) + if compte + ) + print(f" {t('Left intact')} : {detail}") + if bilan.get("hors_portee"): + print( + f" {bilan['hors_portee']}" + f" {t('cell(s) left out of scope')}" + ) + hors = bilan.get("hors_cellules") or {} + # `isinstance(True, int)` vaut True : sans exclure les booléens, le + # drapeau des macros comptait pour un élément effacé. + efface = sum( + v + for v in hors.values() + if isinstance(v, int) and not isinstance(v, bool) + ) + if efface: + print(f" {efface} {t('element(s) outside cells wiped')}") + non_verifiees = bilan.get("valeurs_non_verifiees") or 0 + if non_verifiees: + print( + f" ⚠ {non_verifiees}" + f" {t('value(s) not verified, above the cap')}" + ) + print(f" {t('The original was not modified.')}") + if bilan.get("table"): + print(f" {t('Mapping table written: ')}{bilan['table']}") + avis = t("This table re-identifies the copy; keep it in private/.") + print(f" ⚠ {avis}") + for avertissement in bilan.get("avertissements") or []: + print(f" ⚠ {t(avertissement)}") + if self._transform_sous_private(destination): + avis = t( + "private/ is tracked by git — this file will show in" + " « git status »." + ) + print(f" ⚠ {avis}") + + @staticmethod + def _transform_sous_private(destination): + """La destination est-elle sous `private/` du dépôt ? + + Comparaison de `realpath` ancrés : un test de préfixe sur le chemin + tel qu'il est tapé taisait l'avertissement pour le chemin ABSOLU + que rend le navigateur — c'est-à-dire le cas le plus courant — et + le levait à tort pour « privateer/ ». + """ + prive = os.path.realpath( + os.path.join(transform_setup.racine(), "private") + ) + cible = os.path.realpath(os.path.abspath(str(destination))) + return cible == prive or cible.startswith(prive + os.sep) + + @staticmethod + def _transform_format(chemin): + """Le format présumé, pour choisir l'interpréteur AVANT de lire. + + Le moteur retranchera par les octets ; ici l'extension suffit, + puisqu'il ne s'agit que de savoir s'il faut le venv dédié. + """ + extension = os.path.splitext(chemin)[1].lower() + return { + ".xlsx": "xlsx", + ".xlsm": "xlsx", + ".xlsb": "xlsx", + ".xls": "xls", + ".mdb": "access", + ".accdb": "access", + ".csv": "csv", + ".xml": "xml", + ".json": "json", + }.get(extension, "xlsx") + + # ------------------------------------------------------------------ + # Une base Odoo, ou une sauvegarde + # ------------------------------------------------------------------ + def _transform_anonymise_base(self): + """Anonymiser une base Odoo, ou une sauvegarde qui en redevient une. + + Rien n'est réécrit ici : `anonymize.py` sait déjà remplacer les + données d'une base, et `_monitoring_write_flow` sait déjà montrer + puis demander, avec le nom retapé pour confirmer. Cette méthode ne + fait que choisir la SOURCE et, quand c'en était un, refaire un zip. + + Un zip n'est jamais modifié : il est restauré dans une base, qui + est anonymisée, puis vidée dans un zip NEUF. Une base locale, elle, + est modifiée EN PLACE — l'invite le dit, et le nom retapé le + confirme. + """ + from script.analyse import monitoring + + analyse = next( + (a for a in monitoring.ANALYSES if a["key"] == "anonymize"), None + ) + if analyse is None: # pragma: no cover - la table est en dur + print(f"❌ {t('Command not found !')}") + return + choix = self._transform_anonymise_source() + if choix is None: + print(t("Nothing to do.")) + return + base, depuis_zip = choix + print() + print(monitoring.describe_source(monitoring.KIND_DATABASE, base)) + if not depuis_zip: + # La base MÊME est modifiée : le dire avant, non après. + print(f"⚠ {t('The database ITSELF is modified; no copy.')}") + ecrit = self._monitoring_write_flow(analyse, base) + # APRÈS le travail, et quel que soit son sort : une base restaurée + # existe sur le serveur même si l'anonymisation a été refusée, et + # c'est précisément celle-là qu'il faut pouvoir retrouver. + self._transform_noter_base(base, depuis_zip) + if not ecrit: + # Sans cette garde, un renoncement produisait un zip de la + # base NON anonymisée, annoncé comme le résultat d'une + # anonymisation — le pire des deux mondes : l'opérateur croit + # tenir une copie transmissible et tient l'original. + print(f" {t('Nothing was written; no backup was drawn.')}") + print(f" {t('Database kept: ')}{base}") + return + if depuis_zip: + self._transform_export_zip(base) + + def _transform_anonymise_source(self): + """(nom de base, venait d'un zip), ou None si l'on renonce. + + Les trois mêmes provenances que « Monitoring », dans le même + ordre : deux ordres différents dans un même logiciel se paient en + hésitation à chaque usage. + """ + print(f"[1] {t('A local database')}") + print(f"[2] {t('A local backup .zip')}") + print(f"[3] {t('A remote backup (https + master password)')}") + print(f"[0] {t('Back')}") + reponse = click.prompt(t("Command:"), prompt_suffix=" ") + print() + if reponse == "1": + base = self.db_manager.select_database() + return (base, False) if base else None + if reponse == "2": + chemin = self.db_manager.select_backup_path() + base = self._monitoring_restore(chemin) if chemin else None + return (base, True) if base else None + if reponse == "3": + statut, chemin, _nom = ( + self.db_manager.download_database_backup_cli() + ) + if statut or not chemin or not os.path.isfile(chemin): + print(f"❌ {t('The download did not produce a usable file.')}") + return None + base = self._monitoring_restore(chemin) + return (base, True) if base else None + return None + + def _transform_export_zip(self, base): + """Vider la base anonymisée dans un zip NEUF. + + Par la sauvegarde d'Odoo lui-même — `odoo_bin.sh db --backup` — + et non par un `pg_dump` à nous : c'est Odoo qui écrit le + `dump.sql`, le `filestore/` et le `manifest.json`, donc le format + est juste par construction. Le zip atterrit dans `image_db/`, + d'où les sauvegardes viennent et où le navigateur les cherche. + + Un nom déjà pris n'est jamais écrasé en silence : l'invite le dit + et propose un nom frais, que l'opérateur peut changer. + """ + defaut = "%s_anon_%s" % ( + base, + datetime.datetime.now().strftime("%Y%m%d-%H%M%S"), + ) + nom = self._transform_ask( + f"{t('Backup name (Enter to accept): ')}{defaut} " + ) + if nom is None: + print(t("Nothing to do.")) + return + nom = (nom or defaut).removesuffix(".zip") + cible = os.path.join( + transform_setup.racine(), "image_db", nom + ".zip" + ) + if os.path.isfile(cible): + print(f" {t('These files would be overwritten: ')}") + print(f" {cible}") + if not self._transform_confirm_overwrite([cible]): + return + statut, _sortie = self.execute.exec_command_live( + f"./odoo_bin.sh db --backup --database {base}" + f" --restore_image {nom}", + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + if statut: + print(f"❌ {t('The download did not produce a usable file.')}") + return + print(f"✅ {t('Backup written: ')}{cible}") + print(f" {t('The original was not modified.')}") + print(f" {t('Database kept: ')}{base}") + + def _transform_noter_base(self, base, depuis_zip): + """Inscrire la base produite au registre, sans jamais lever. + + Le serveur ne dit pas QUI a créé une base : sans registre, cette + entrée ne pourrait que lister toutes les bases de la machine, ce + que le menu Database fait déjà. Le registre est ce qui lui permet + de ne montrer que ce que Transform data a produit. + + Un registre illisible ou impossible à écrire ne doit pas emporter + le travail : il est un confort, comme la mémoire de lot. + """ + registre = os.path.join(SORTIE_PAR_DEFAUT, "bases.json") + connues = self._transform_bases_lues() + connues = [b for b in connues if b.get("base") != base] + connues.append( + { + "base": base, + "depuis_zip": bool(depuis_zip), + "date": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + ) + try: + os.makedirs(SORTIE_PAR_DEFAUT, mode=0o700, exist_ok=True) + with open(registre, "w", encoding="utf-8") as flux: + json.dump(connues, flux, ensure_ascii=False, indent=1) + except OSError: + pass + + @staticmethod + def _transform_bases_lues(): + """Le registre, ou une liste vide s'il ne se lit pas.""" + registre = os.path.join(SORTIE_PAR_DEFAUT, "bases.json") + try: + with open(registre, "r", encoding="utf-8") as flux: + lues = json.load(flux) + except (OSError, ValueError): + return [] + return [b for b in lues if isinstance(b, dict) and b.get("base")] + + def _transform_bases_produites(self): + """Lister les bases que cette entrée a produites, et les effacer. + + Le même raisonnement que « Copies produites » : sans elle, le + serveur devient un tas que rien n'annonce, et le bruit finit par + se lire comme du fond. Une base anonymisée ne porte plus de + donnée client, mais elle occupe le serveur et elle se confond avec + une base de travail. + + Une base inscrite au registre peut avoir été détruite ailleurs — + par le menu Database, par un `db_drop_all`. L'entrée le DIT plutôt + que de la proposer à l'effacement. + """ + inscrites = self._transform_bases_lues() + if not inscrites: + print(f" {t('Empty file.')} {SORTIE_PAR_DEFAUT}") + return + vivantes = self._transform_bases_vivantes() + presentes, disparues = [], [] + for entree in inscrites: + cible = presentes if entree["base"] in vivantes else disparues + cible.append(entree) + for entree in presentes: + marque = "📦" if entree.get("depuis_zip") else "🗄" + print(f" {marque} {entree['base']:<40} {entree['date']}") + print(f" {len(presentes)} {t('Database:')}") + if disparues: + print() + print(f" {t('Gone already, only in the register:')}") + for entree in disparues: + print(f" {entree['base']}") + if not presentes: + print(t("Nothing to do.")) + return + print(f" {t('Use Database to drop one.')}") + + def _transform_bases_vivantes(self): + """Les bases que le serveur porte VRAIMENT, ou () si on ne sait pas. + + La MÊME commande que `select_database` — `odoo_bin.sh db --list` — + et non un appel à psql de plus : deux façons de demander la même + chose se contredisent le jour où l'une change. + + Une liste vide et une ignorance ne se confondent pas : sans + serveur joignable, tout le registre paraîtrait disparu. Les deux + rendent `()`, et l'appelant ne propose alors aucun effacement. + """ + statut, sortie = self.execute.exec_command_live( + "./odoo_bin.sh db --list", + return_status_and_output=True, + quiet=True, + source_erplibre=False, + single_source_erplibre=True, + ) + if statut: + return () + return {ligne.strip() for ligne in sortie if ligne.strip()} + + # ------------------------------------------------------------------ + # L'environnement + # ------------------------------------------------------------------ + def _transform_install_env(self): + if transform_setup.available("xlsx"): + print(f"✅ {t('The environment is ready.')}") + else: + transform_setup.create() + # La PRÉSENCE d'abord, le paquet ensuite. Demander au gestionnaire + # de paquets si l'outil est là rendait « aucun paquet connu » + # devant un `mdb-queries` installé — vrai sur une distribution + # dont ce module ne connaît pas le paquet, et faux sur ce que + # l'opérateur voit dans son PATH. + if transform_setup.requetes_access_lisibles(): + print(f"✅ {t('Access saved queries are readable here.')}") + return + commande = transform_setup.system_packages_cmd() + if commande is None: + print(f" {t('No package known here for:')} mdb-queries") + print( + f" {t('Install it by hand to read Access saved queries.')}" + ) + return + print(f"\n{t('Access files need a system package.')}") + print(f"{t('The installation requires sudo.')}") + from script.todo import todo_install + + todo_install.ask_and_install( + self.execute, + commande, + t("Create it now? (Y/n): "), + self._is_yes_default_yes, + ) + + def _transform_capabilities(self): + for fmt, disponible in transform_setup.capabilities().items(): + etat = t("readable") if disponible else t("not readable") + print(f" {fmt:<8} {etat}") + + def _transform_copies(self): + """Lister les copies produites, et proposer de les effacer. + + Rien n'est ajouté à `.gitignore` : les copies restent visibles dans + « git status », ce qui est le rappel qu'elles existent et qu'elles + portent de la donnée client. Cette entrée est ce qui rend ce choix + vivable — sans elle, le répertoire deviendrait un tas que rien + n'annonce, et le bruit finirait par se lire comme du fond. + """ + if not os.path.isdir(SORTIE_PAR_DEFAUT): + print(f" {t('Empty file.')} {SORTIE_PAR_DEFAUT}") + return + produites, etrangeres = self._transform_inventaire() + if not produites and not etrangeres: + print(f" {t('Empty file.')} {SORTIE_PAR_DEFAUT}") + return + total = 0 + for nom, taille in produites: + total += taille + print(f" {nom:<44} {taille:>10} o") + print(f" {len(produites)} {t('File')} — {total} o") + if etrangeres: + # Le navigateur de fichiers ouvre son parcours dans ce même + # répertoire : ce qui s'y trouve n'est pas toujours une copie + # produite ici, et l'effacement en bloc l'emportait aussi. + print(f"\n {t('Not produced here, left alone:')}") + for nom, taille in etrangeres: + print(f" {nom:<44} {taille:>10} o") + if not produites: + print(t("Nothing to do.")) + return + reponse = input( + t("Delete all copies in private/transform/? (y/N): ") + ).strip() + if not self._is_yes(reponse): + print(t("Nothing to do.")) + return + for nom, _taille in produites: + chemin = os.path.join(SORTIE_PAR_DEFAUT, nom) + try: + if os.path.isdir(chemin): + # Une source à plusieurs feuilles convertie en csv + # écrit un RÉPERTOIRE. Il était listé comme un fichier + # avec sa taille d'inode, puis laissé sur le disque, + # plein de lignes dérivées du client. + shutil.rmtree(chemin) + else: + os.unlink(chemin) + except OSError as exc: + print(f" ⚠ {exc}") + + @staticmethod + def _transform_inventaire(): + """(produites ici, étrangères) — chacune en (nom, octets). + + Ce que l'outil produit se reconnaît à son nom : « .anon. » quelque + part, ou le suffixe de la table. Tout le reste est à quelqu'un + d'autre et n'est pas à effacer. + """ + produites, etrangeres = [], [] + for nom in sorted(os.listdir(SORTIE_PAR_DEFAUT)): + chemin = os.path.join(SORTIE_PAR_DEFAUT, nom) + taille = 0 + if os.path.isdir(chemin): + for racine, _dossiers, fichiers in os.walk(chemin): + for fichier in fichiers: + try: + taille += os.path.getsize( + os.path.join(racine, fichier) + ) + except OSError: + continue + else: + try: + taille = os.path.getsize(chemin) + except OSError: + continue + cible = ( + produites + if ".anon." in nom or nom.endswith(".table.json") + else etrangeres + ) + cible.append((nom, taille)) + return produites, etrangeres diff --git a/script/todo/transform_setup.py b/script/todo/transform_setup.py new file mode 100644 index 0000000..1cb47e3 --- /dev/null +++ b/script/todo/transform_setup.py @@ -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) diff --git a/test/test_anonymize.py b/test/test_anonymize.py index 28a3210..302a479 100644 --- a/test/test_anonymize.py +++ b/test/test_anonymize.py @@ -24,6 +24,9 @@ blanche, aucune insistance ne doit permettre d'écrire dans `ir.*`. """ import ast +import io +import math +import sys import unittest from pathlib import Path @@ -578,6 +581,143 @@ class TestACheckDoesNotSilenceTheMainField(unittest.TestCase): for motif in ("char_length", "~~", "jsonb_typeof"): 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): """`checked=False` doit suffire : aucune seconde barrière cachée.""" champ = { @@ -893,5 +1033,788 @@ class TestTheProbeDistrustsWhatItReads(unittest.TestCase): 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 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 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__": unittest.main() diff --git a/test/test_todo.py b/test/test_todo.py index 9c9534e..65e8ef4 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -2,14 +2,19 @@ # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import ast +import builtins +import io import json import os import subprocess +import sys import tempfile import unittest from pathlib import Path from unittest.mock import MagicMock, mock_open, patch +from script.todo import todo_i18n from script.todo.todo import ( ANDROID_DIR, CONFIG_FILE, @@ -806,5 +811,293 @@ class TestModuleLevelAbortExit(unittest.TestCase): 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__": unittest.main() diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 4e4855a..7c8a3b1 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -97,6 +97,7 @@ class TestExecuteMenuNumbering(unittest.TestCase): "Process": "prompt_execute_process", "Database": "prompt_execute_database", "Analyse": "prompt_execute_analyse", + "Transform data": "prompt_execute_transform", "Git": "prompt_execute_git", "Doc": "prompt_execute_doc", "GPT code": "prompt_execute_gpt_code", diff --git a/test/test_transform_anonymise.py b/test/test_transform_anonymise.py new file mode 100644 index 0000000..dacfdfc --- /dev/null +++ b/test/test_transform_anonymise.py @@ -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() diff --git a/test/test_transform_external.py b/test/test_transform_external.py new file mode 100644 index 0000000..83b4e56 --- /dev/null +++ b/test/test_transform_external.py @@ -0,0 +1,7206 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Transformer un fichier externe : ce qui compte est ce qui NE sort PAS. + +Remplacer une cellule est facile. Ce qui produit un fichier faussement +propre, c'est de croire qu'un classeur ne porte de la donnée que dans ses +cellules. Un `.xlsx` en porte dans une douzaine d'autres endroits, et deux +d'entre eux — le cache d'un tableau croisé et un lien externe — en portent +une COPIE entière. + +Le test de fuite est donc le cœur de ce fichier. Il pose 24 marqueurs +inventés, un par vecteur : 19 disparaissent, et 5 restent — ces cinq étant +référencés par des formules que la règle préserve, et qu'on ne peut donc +pas supprimer sans casser ce qu'on vient de garantir. Il assert la liste +EXACTE des survivants et non « rien d'autre » : c'est ce qui le fait tomber +quand une montée de version d'openpyxl rouvre un vecteur. + +Trois pièges que seule l'exécution a donnés, et que ce fichier fige : +`isinstance(True, int)` vaut True, donc une case à cocher deviendrait un +montant ; `xlrd` stocke une erreur par son CODE ENTIER, donc `#REF!` +deviendrait un montant plausible et `#NULL!` un zéro légitime ; et +`csv.reader` ne rend que des chaînes, donc une colonne de montants +deviendrait des mots. + +La suite tourne sous `.venv.erplibre`, qui n'a pas openpyxl : tout ce qui +en dépend est derrière `skipUnless` et se DIT ignoré, jamais vert en +silence. +""" + +import builtins +import datetime +import io +import json +import os +import random +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + +from script.data import external_file as noyau +from script.data import external_file_formats as formats +from script.todo import todo_i18n, transform_setup + +VIVIER = noyau.vivier_de_mots() + + +def _options(**extra): + base = { + "vivier": VIVIER, + "nombres": True, + "texte": True, + "entetes": False, + "feuilles": None, + "etiquettes": {}, + "colonnes_intactes": set(), + "bornes": {}, + } + base.update(extra) + return base + + +# `transform_menu` et `todo.py` importent `click`, que le venv dédié n'a +# pas — et la fixture du test de fuite importe CE module sous cet +# interprète-là pour bâtir un classeur. Les imports du menu sont donc +# PARESSEUX, comme ceux du moteur : sans cela, tout le test de fuite tombe +# sur un `ModuleNotFoundError` sans rapport avec ce qu'il éprouve. +def _tm(): + """Le module du menu, importé au premier appel.""" + from script.todo import transform_menu + + return transform_menu + + +def _MenuBouchon(): + """Le mixin, monté sur les aides que `TODO` lui fournit. + + Les aides sont PRISES sur la vraie classe et non recopiées : « o », + « oui », « y », « yes » 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) + + return Bouchon() + + +class _Entrees: + """`input()` bouchonné, et la trace de ce qui a été demandé. + + Rend les réponses dans l'ordre. Une question de plus que de réponses + lève : un test qui répondrait « à côté » passerait sinon en silence. + """ + + 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 TestMenuFonctionsPures(unittest.TestCase): + """Ce que le menu décide sans rien demander.""" + + def setUp(self): + self.menu = _MenuBouchon() + + def test_le_format_vient_de_l_extension(self): + for nom, attendu in ( + ("a.xlsx", "xlsx"), + ("a.XLSM", "xlsx"), + ("a.xlsb", "xlsx"), + ("a.xls", "xls"), + ("a.mdb", "access"), + ("a.accdb", "access"), + ("a.csv", "csv"), + ("a.xml", "xml"), + ("a.json", "json"), + ): + with self.subTest(nom=nom): + self.assertEqual(self.menu._transform_format(nom), attendu) + + def test_une_extension_inconnue_tombe_sur_le_venv_dedie(self): + """Le repli doit être le venv qui sait TOUT lire : celui du CLI + ferait lever un import sur un classeur mal nommé.""" + self.assertEqual(self.menu._transform_format("a.dat"), "xlsx") + self.assertEqual(self.menu._transform_format("sans_extension"), "xlsx") + + def test_l_interpreteur_suit_l_union_des_bibliotheques(self): + """Le processus lit la source ET écrit la cible. + + La source seule laissait csv→xlsx importer openpyxl sous + l'interpréteur du CLI, qui ne l'a pas ; la cible seule enverrait + un classeur au même interpréteur, qui ne sait pas le lire. + """ + self.assertEqual( + self.menu._transform_fmt_moteur("csv", "xlsx"), "xlsx" + ) + self.assertEqual(self.menu._transform_fmt_moteur("xls", "csv"), "xls") + self.assertEqual( + self.menu._transform_fmt_moteur("csv", "json"), "json" + ) + self.assertEqual( + self.menu._transform_fmt_moteur("access", "xlsx"), "access" + ) + + def test_la_table_va_TOUJOURS_sous_private(self): + """Elle ré-identifie la copie à elle seule : la poser à côté du + fichier à transmettre fait partir la clé avec le chiffré.""" + for destination in ( + "/tmp/livraison/copie.xlsx", + os.path.join("private", "transform", "c.xlsx"), + "c.csv", + ): + with self.subTest(destination=destination): + table = self.menu._transform_table_par_defaut(destination) + self.assertTrue( + table.startswith(_tm().SORTIE_PAR_DEFAUT), table + ) + self.assertTrue(table.endswith(".table.json"), table) + + def test_deux_livraisons_de_meme_nom_ne_partagent_pas_leur_table(self): + """« vide = nouvelle » est une promesse. + + Le nom ne tenait qu'au nom de BASE : « 2026/export.xlsx » et + « 2027/export.xlsx » donnaient le même chemin, et le moteur + chargeait la table de la livraison précédente — ses mots, ses + nombres, ses empans — sans que rien ne signale sa présence. + """ + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base, True) + vrai = _tm().SORTIE_PAR_DEFAUT + _tm().SORTIE_PAR_DEFAUT = base + self.addCleanup(setattr, _tm(), "SORTIE_PAR_DEFAUT", vrai) + + premiere = self.menu._transform_table_par_defaut( + "/tmp/livraison/2026/export.xlsx" + ) + with open(premiere, "w", encoding="utf-8") as flux: + flux.write("{}") + seconde = self.menu._transform_table_par_defaut( + "/tmp/livraison/2027/export.xlsx" + ) + self.assertNotEqual(premiere, seconde) + self.assertFalse(os.path.exists(seconde)) + self.assertTrue(seconde.endswith(".table.json"), seconde) + + def test_un_nom_libre_garde_le_nom_de_la_destination(self): + """Il reste le plus utile pour retrouver la table d'une copie.""" + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base, True) + vrai = _tm().SORTIE_PAR_DEFAUT + _tm().SORTIE_PAR_DEFAUT = base + self.addCleanup(setattr, _tm(), "SORTIE_PAR_DEFAUT", vrai) + chemin = self.menu._transform_table_par_defaut("/tmp/l/export.xlsx") + self.assertEqual( + chemin, self.menu._transform_table_derivee("/tmp/l/export.xlsx") + ) + self.assertEqual(os.path.basename(chemin), "export.table.json") + + def test_private_se_reconnait_par_realpath(self): + """Un test de préfixe sur le chemin TAPÉ taisait l'avertissement + pour le chemin absolu du navigateur, et le levait pour + « privateer/ ».""" + racine = transform_setup.racine() + self.assertTrue( + self.menu._transform_sous_private( + os.path.join(racine, "private", "transform", "c.xlsx") + ) + ) + self.assertTrue( + self.menu._transform_sous_private(os.path.join(racine, "private")) + ) + self.assertFalse( + self.menu._transform_sous_private( + os.path.join(racine, "privateer", "c.xlsx") + ) + ) + self.assertFalse(self.menu._transform_sous_private("/tmp/c.xlsx")) + + +class TestMenuQuestions(unittest.TestCase): + """Les douze questions, et la sortie qui doit exister à chacune. + + S'apercevoir à la onzième qu'on a ouvert le mauvais fichier ne doit + pas obliger à répondre à tout puis à refuser un nom de fichier. + """ + + RAPPORT = { + "format": "xlsx", + "feuilles": [{"nom": "Ventes"}, {"nom": "Achats"}], + "hors_cellules": {}, + } + + def setUp(self): + self.menu = _MenuBouchon() + # L'écran réel prendrait le terminal : ce qu'on éprouve ici est le + # chemin TEXTUEL, dont l'écran est le repli. `{}` veut dire + # « pose-moi les questions ». + self.menu._transform_ecran = lambda *args: {} + self.vrai_input = builtins.input + self.addCleanup(setattr, builtins, "input", self.vrai_input) + self.sortie = io.StringIO() + self.vrai_stdout = sys.stdout + sys.stdout = self.sortie + self.addCleanup(setattr, sys, "stdout", self.vrai_stdout) + + def _repondre(self, *reponses): + entrees = _Entrees(*reponses) + builtins.input = entrees + return entrees + + # -- le chemin qui aboutit ----------------------------------------- + def test_le_chemin_complet_rend_les_douze_reponses(self): + self._repondre( + "o", # anonymiser + "csv", # convertir + "", # table du lot — posée AVANT l'écran + "o", # options avancées + "Ventes", # feuilles + "id, ref", # colonnes intactes + "", # nombres, défaut oui + "", # calibre des chiffres, défaut non + "", # texte, défaut oui + "", # en-têtes, défaut non + "42", # graine + ) + options = self.menu._transform_ask_options(self.RAPPORT) + self.assertEqual(options["conversion"], "csv") + self.assertEqual(options["feuilles"], ["Ventes"]) + self.assertEqual(options["colonnes_intactes"], ["id", "ref"]) + self.assertTrue(options["nombres"]) + self.assertTrue(options["texte"]) + self.assertFalse(options["entetes"]) + self.assertEqual(options["graine"], "42") + self.assertEqual(options["table_chemin"], "") + + def test_le_chemin_COURT_ne_pose_que_quatre_questions(self): + """Sept questions dont chacune a 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 ne consent à rien. + """ + entrees = self._repondre("o", "", "", "n") + options = self.menu._transform_ask_options(self.RAPPORT) + self.assertEqual(len(entrees.demandes), 4) + for cle, valeur in _tm().DEFAUTS_RAPIDES.items(): + with self.subTest(cle=cle): + self.assertEqual(options[cle], valeur) + + def test_le_chemin_court_GARDE_les_largeurs(self): + """Le seul choix qui ne suit pas le défaut de son invite. Ce que + l'option abandonne — l'étendue mesurée — est dit par l'aperçu.""" + self._repondre("o", "", "", "n") + options = self.menu._transform_ask_options(self.RAPPORT) + self.assertTrue(options["calibre_chiffres"]) + + def test_le_chemin_court_DIT_ce_qu_il_prend(self): + """Un raccourci qui ne dit pas ce qu'il choisit n'est pas un + raccourci mais une surprise.""" + self._repondre("o", "", "", "n") + self.menu._transform_ask_options(self.RAPPORT) + rendu = self.sortie.getvalue() + self.assertIn(todo_i18n.t("Taken as given:"), rendu) + self.assertIn(todo_i18n.t("every sheet"), rendu) + self.assertIn(todo_i18n.t("numbers replaced, widths kept"), rendu) + + def test_le_chemin_court_pose_QUAND_MEME_macros_et_graphiques(self): + """Elles ne se voient pas dans les cellules et ne se devinent + pas : la réponse change ce que la copie porte.""" + entrees = self._repondre("o", "", "", "n", "o", "n") + rapport = dict( + self.RAPPORT, hors_cellules={"macros": 1, "graphiques": 2} + ) + options = self.menu._transform_ask_options(rapport) + self.assertTrue(options["garder_macros"]) + self.assertFalse(options["garder_graphiques"]) + self.assertEqual(len(entrees.demandes), 6) + + def test_le_chemin_court_n_efface_pas_les_reponses_de_l_ECRAN(self): + """L'écran a pu répondre par feuille : refuser les options + avancées ne doit pas effacer ce qu'on vient de cocher.""" + self.menu._transform_ecran = lambda *args: { + "colonnes_intactes_index_par_feuille": {"Ventes": [2]}, + "entetes_par_feuille": {"Ventes": [1]}, + } + self._repondre("o", "", "", "n") + options = self.menu._transform_ask_options(self.RAPPORT) + self.assertEqual( + options["colonnes_intactes_index_par_feuille"], {"Ventes": [2]} + ) + self.assertEqual(options["colonnes_intactes"], []) + + def test_le_resume_DIT_les_colonnes_gelees_a_l_ecran(self): + """Le chemin court garde ce que l'écran a gelé : annoncer + « aucune colonne laissée intacte » dirait le contraire de ce qui + s'applique, sur le seul écran où l'opérateur consent.""" + self.menu._transform_ecran = lambda *args: { + "colonnes_intactes_index_par_feuille": {"Ventes": [2, 5]}, + "entetes_par_feuille": {"Ventes": [1]}, + } + self._repondre("o", "", "", "n") + self.menu._transform_ask_options(self.RAPPORT) + rendu = self.sortie.getvalue() + self.assertIn( + todo_i18n.t("column(s) left untouched, chosen on screen"), rendu + ) + self.assertIn("2 ", rendu) + self.assertNotIn(todo_i18n.t("no column left untouched"), rendu) + + def test_le_resume_dit_AUCUNE_quand_l_ecran_n_a_rien_gele(self): + self._repondre("o", "", "", "n") + self.menu._transform_ask_options(self.RAPPORT) + rendu = self.sortie.getvalue() + self.assertIn(todo_i18n.t("no column left untouched"), rendu) + self.assertNotIn( + todo_i18n.t("column(s) left untouched, chosen on screen"), rendu + ) + + def test_le_resume_DIT_la_feuille_que_l_ecran_a_privee_d_en_tete(self): + """Un empan VIDE veut dire « pas d'en-tête », et l'écran y mène + d'une frappe : la première ligne devient de la donnée et se fait + remplacer. Annoncer l'en-tête protégé dit alors le contraire de + ce qui s'applique.""" + self.menu._transform_ecran = lambda *args: { + "colonnes_intactes_index_par_feuille": {}, + "entetes_par_feuille": {"Ventes": []}, + } + self._repondre("o", "", "", "n") + self.menu._transform_ask_options(self.RAPPORT) + rendu = self.sortie.getvalue() + self.assertIn( + todo_i18n.t("sheet(s) with no header row on screen"), rendu + ) + self.assertNotIn(todo_i18n.t("the header row is left readable"), rendu) + + def test_un_empan_declare_laisse_l_en_tete_protege(self): + self.menu._transform_ecran = lambda *args: { + "colonnes_intactes_index_par_feuille": {}, + "entetes_par_feuille": {"Ventes": [1]}, + } + self._repondre("o", "", "", "n") + self.menu._transform_ask_options(self.RAPPORT) + rendu = self.sortie.getvalue() + self.assertIn(todo_i18n.t("the header row is left readable"), rendu) + self.assertNotIn( + todo_i18n.t("sheet(s) with no header row on screen"), rendu + ) + + def test_une_feuille_ABSENTE_de_la_table_garde_son_en_tete(self): + """Absente veut dire « mesure-la », non « elle n'en a pas ».""" + self.menu._transform_ecran = lambda *args: { + "colonnes_intactes_index_par_feuille": {}, + "entetes_par_feuille": {}, + } + self._repondre("o", "", "", "n") + self.menu._transform_ask_options(self.RAPPORT) + self.assertIn( + todo_i18n.t("the header row is left readable"), + self.sortie.getvalue(), + ) + + def test_zero_annule_a_la_porte_des_options_avancees(self): + self._repondre("o", "", "", "0") + self.assertIsNone(self.menu._transform_ask_options(self.RAPPORT)) + + def test_un_defaut_vide_ne_convertit_ni_ne_restreint(self): + self._repondre("oui", "", "", "o", "", "", "n", "n", "o", "") + options = self.menu._transform_ask_options(self.RAPPORT) + self.assertEqual(options["conversion"], "") + self.assertEqual(options["feuilles"], []) + self.assertFalse(options["nombres"]) + self.assertFalse(options["texte"]) + self.assertTrue(options["entetes"]) + + # -- les sorties --------------------------------------------------- + def test_refuser_d_anonymiser_ne_pose_aucune_autre_question(self): + entrees = self._repondre("n") + self.assertIsNone(self.menu._transform_ask_options(self.RAPPORT)) + self.assertEqual(len(entrees.demandes), 1) + + def test_zero_annule_a_CHAQUE_question(self): + """Une réponse valide jusqu'au rang N, puis « 0 ».""" + # L'ordre du dialogue, la table venant maintenant en troisième. + valides = ["o", "csv", "", "o", "Ventes", "", "", "", "", "", "42"] + for rang in range(len(valides)): + with self.subTest(rang=rang): + entrees = self._repondre(*(valides[:rang] + ["0"])) + self.assertIsNone( + self.menu._transform_ask_options(self.RAPPORT) + ) + self.assertEqual(len(entrees.demandes), rang + 1) + + def test_une_cible_inconnue_est_refusee(self): + entrees = self._repondre("o", "parquet") + self.assertIsNone(self.menu._transform_ask_options(self.RAPPORT)) + self.assertEqual(len(entrees.demandes), 2) + + def test_les_quatre_cibles_sont_acceptees(self): + for cible in _tm().CIBLES: + with self.subTest(cible=cible): + self._repondre("o", cible, "", "o", "", "", "", "", "", "", "") + options = self.menu._transform_ask_options(self.RAPPORT) + self.assertEqual(options["conversion"], cible) + + # -- les feuilles -------------------------------------------------- + def test_une_feuille_inconnue_est_refusee_AVANT_d_ecrire(self): + entrees = self._repondre("o", "", "", "o", "Trésorerie") + self.assertIsNone(self.menu._transform_ask_options(self.RAPPORT)) + self.assertEqual(len(entrees.demandes), 5) + + def test_le_nom_de_feuille_se_resout_sans_la_casse(self): + """Le nom rendu est celui du CLASSEUR, non celui tapé : la portée + s'apparie ensuite par égalité exacte.""" + self._repondre( + "o", "", "", "o", " ventes , ACHATS ", "", "", "", "", "", "" + ) + options = self.menu._transform_ask_options(self.RAPPORT) + self.assertEqual(options["feuilles"], ["Ventes", "Achats"]) + + def test_une_source_d_une_seule_feuille_ne_pose_pas_la_question(self): + entrees = self._repondre("o", "", "", "o", "", "", "", "", "", "") + rapport = dict(self.RAPPORT, feuilles=[{"nom": "F"}]) + options = self.menu._transform_ask_options(rapport) + self.assertNotIn("feuilles", options) + self.assertEqual(len(entrees.demandes), 10) + + # -- macros et graphiques ------------------------------------------ + def test_les_macros_ne_se_demandent_que_si_le_fichier_en_a(self): + entrees = self._repondre( + "o", "", "", "o", "", "", "", "", "", "", "", "o", "n" + ) + rapport = dict( + self.RAPPORT, hors_cellules={"macros": 1, "graphiques": 2} + ) + options = self.menu._transform_ask_options(rapport) + self.assertTrue(options["garder_macros"]) + self.assertFalse(options["garder_graphiques"]) + self.assertEqual(len(entrees.demandes), 13) + + def test_une_conversion_hors_xlsx_ne_les_demande_pas(self): + """Un csv ne porte ni macro ni graphique : poser la question + laisserait croire que la réponse change quelque chose.""" + entrees = self._repondre( + "o", "csv", "", "o", "", "", "", "", "", "", "" + ) + rapport = dict( + self.RAPPORT, hors_cellules={"macros": 1, "graphiques": 2} + ) + options = self.menu._transform_ask_options(rapport) + self.assertNotIn("garder_macros", options) + self.assertEqual(len(entrees.demandes), 11) + + +class _Acheve: + """Ce que `subprocess.run` rend, réduit à ce que le menu en lit.""" + + def __init__(self, stdout="", stderr="", returncode=0): + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +class TestMenuMoteurEnSousProcessus(unittest.TestCase): + """stdout ne porte qu'un objet JSON, stderr la progression. + + Un stdout vide ou inanalysable est traité comme une ERREUR et non + relayé en exception, pour que le menu affiche les dernières lignes de + stderr plutôt que de tomber à son tour. + """ + + def setUp(self): + self.menu = _MenuBouchon() + self.sortie = io.StringIO() + vrai = sys.stdout + sys.stdout = self.sortie + self.addCleanup(setattr, sys, "stdout", vrai) + self.vrai_run = _tm().subprocess.run + self.addCleanup(setattr, _tm().subprocess, "run", self.vrai_run) + + def _rendre(self, acheve): + _tm().subprocess.run = lambda *a, **k: acheve + + def test_un_objet_json_traverse(self): + self._rendre(_Acheve(stdout='{"format": "csv", "taille": 12}')) + self.assertEqual( + self.menu._transform_run(["--report", "a.csv"], "csv"), + {"format": "csv", "taille": 12}, + ) + + def test_la_progression_de_stderr_s_affiche_sans_son_diese(self): + self._rendre( + _Acheve(stdout="{}", stderr="# lecture en cours\nbruit interne") + ) + self.menu._transform_run(["--report", "a.csv"], "csv") + rendu = self.sortie.getvalue() + self.assertIn("lecture en cours", rendu) + self.assertNotIn("bruit interne", rendu) + + def test_un_stdout_vide_ne_leve_pas_et_montre_stderr(self): + """Le menu doit afficher la cause, non tomber à son tour.""" + self._rendre(_Acheve(stdout="", stderr="Traceback ligne 1")) + self.assertIsNone( + self.menu._transform_run(["--report", "a.csv"], "csv") + ) + self.assertIn("Traceback ligne 1", self.sortie.getvalue()) + + def test_un_stdout_inanalysable_est_traite_comme_une_erreur(self): + self._rendre(_Acheve(stdout="pas du json")) + self.assertIsNone( + self.menu._transform_run(["--report", "a.csv"], "csv") + ) + + def test_une_erreur_du_moteur_s_affiche_avec_son_detail(self): + self._rendre( + _Acheve( + stdout=json.dumps( + {"erreur": "Nothing to do.", "detail": " ici"} + ) + ) + ) + self.assertIsNone( + self.menu._transform_run(["--apply", "a.csv"], "csv") + ) + self.assertIn("ici", self.sortie.getvalue()) + + def test_le_CONSEIL_s_affiche_quand_le_refus_en_porte_un(self): + """Le détail nomme une partie du format ; le conseil dit quoi + répondre à la prochaine exécution.""" + avis = ( + "The kept VBA project quotes a source value;" + " answer no to the macro question to write the copy." + ) + self._rendre( + _Acheve( + stdout=json.dumps( + {"erreur": "Nothing to do.", "conseil": avis} + ) + ) + ) + self.assertIsNone( + self.menu._transform_run(["--apply", "a.xlsm"], "xlsx") + ) + rendu = self.sortie.getvalue() + self.assertIn("→", rendu) + self.assertIn(todo_i18n.TRANSLATIONS[avis]["fr"][:30], rendu) + + def test_un_interpreteur_introuvable_ne_leve_pas(self): + def tombe(*a, **k): + raise OSError("introuvable") + + _tm().subprocess.run = tombe + self.assertIsNone( + self.menu._transform_run(["--report", "a.csv"], "csv") + ) + + +class TestMenuApercu(unittest.TestCase): + """Montrer, PUIS demander. La convention du dépôt pour ce qui écrit.""" + + def setUp(self): + self.menu = _MenuBouchon() + self.sortie = io.StringIO() + vrai = sys.stdout + sys.stdout = self.sortie + self.addCleanup(setattr, sys, "stdout", vrai) + vrai_input = builtins.input + self.addCleanup(setattr, builtins, "input", vrai_input) + + def _repondre(self, reponse): + builtins.input = lambda invite="": reponse + + def test_une_reponse_vide_ECRIT(self): + """Le défaut de la dernière question est oui : l'opérateur vient + de lire l'aperçu.""" + self._repondre("") + self.assertTrue(self.menu._transform_preview({"remplacees": 3})) + + def test_un_non_n_ecrit_pas(self): + self._repondre("n") + self.assertFalse(self.menu._transform_preview({"remplacees": 3})) + + def test_l_intact_est_ANNONCE_avant_le_consentement(self): + """Une date et un booléen traversent par RÈGLE et sont de vraies + valeurs du client : les taire faisait signer un consentement sur + un fichier dont une colonne part en clair.""" + self._repondre("") + self.menu._transform_preview( + {"remplacees": 3, "intactes": {"date": 7, "booleen": 2}} + ) + rendu = self.sortie.getvalue() + self.assertIn("7", rendu) + self.assertIn(todo_i18n.t("date(s)"), rendu) + + def test_l_empan_garde_est_montre_cellule_par_cellule(self): + """Un compteur ne dirait pas qu'un nom est dedans.""" + self._repondre("") + self.menu._transform_preview( + { + "remplacees": 1, + "entete_gardee": [{"cellule": "L1C1", "valeur": "aboulie"}], + } + ) + rendu = self.sortie.getvalue() + self.assertIn("L1C1", rendu) + self.assertIn("aboulie", rendu) + + def test_une_colonne_entierement_en_clair_est_NOMMEE(self): + """Le compte par famille dit POURQUOI, non OÙ. + + Sur quarante colonnes, « 7 date(s) laissées » ne dit pas laquelle + sort, et c'est la colonne qui se transmet, non la famille. + """ + self._repondre("") + self.menu._transform_preview( + { + "remplacees": 1, + "colonnes_en_clair": [ + { + "feuille": "Ventes", + "index": 2, + "etiquette": "actif", + "cellules": 3, + }, + { + "feuille": "Ventes", + "index": 5, + "etiquette": "", + "cellules": 3, + }, + ], + } + ) + rendu = self.sortie.getvalue() + self.assertIn(todo_i18n.t("column(s) entirely in clear"), rendu) + self.assertIn("Ventes/actif", rendu) + # Sans étiquette, l'index : sinon la colonne n'est pas désignable. + self.assertIn("Ventes/#5", rendu) + + def test_ce_que_le_plafond_n_a_pas_liste_est_DIT(self): + """Sinon l'opérateur consent sur un extrait pris pour le tout.""" + self._repondre("") + self.menu._transform_preview( + { + "remplacees": 1, + "entete_gardee": [{"cellule": "L1C1", "valeur": "aboulie"}], + "entete_gardee_omises": {"Ventes": 3}, + } + ) + rendu = self.sortie.getvalue() + self.assertIn("Ventes", rendu) + self.assertIn("3", rendu) + self.assertIn(todo_i18n.t("more, not listed"), rendu) + + def test_les_avertissements_sont_traduits(self): + self._repondre("") + avis = "The copy is written in UTF-8, whatever the source was." + self.menu._transform_preview( + {"remplacees": 1, "avertissements": [avis]} + ) + self.assertIn( + todo_i18n.TRANSLATIONS[avis]["fr"][:30], self.sortie.getvalue() + ) + + def test_les_fichiers_prevus_sont_montres(self): + """Ce sur quoi l'opérateur consent inclut OÙ ça va.""" + self._repondre("") + self.menu._transform_preview( + {"remplacees": 1, "fichiers": ["/tmp/a.csv", "/tmp/b.csv"]} + ) + rendu = self.sortie.getvalue() + self.assertIn("/tmp/a.csv", rendu) + self.assertIn("/tmp/b.csv", rendu) + + +class TestMenuDestination(unittest.TestCase): + """Le défaut NE REPREND PAS le nom source : il porte le client.""" + + def setUp(self): + self.menu = _MenuBouchon() + self.sortie = io.StringIO() + vrai = sys.stdout + sys.stdout = self.sortie + self.addCleanup(setattr, sys, "stdout", vrai) + vrai_input = builtins.input + self.addCleanup(setattr, builtins, "input", vrai_input) + + def _repondre(self, reponse): + builtins.input = lambda invite="": reponse + + def test_le_defaut_ne_reprend_pas_le_nom_source(self): + self._repondre("") + rendu = self.menu._transform_select_destination( + "/tmp/Cabinet_Lavigne_2024.xlsx", "xlsx" + ) + self.assertNotIn("Lavigne", rendu) + self.assertTrue(rendu.endswith(".xlsx"), rendu) + self.assertTrue(rendu.startswith(_tm().SORTIE_PAR_DEFAUT), rendu) + + def test_l_extension_suit_le_format_de_SORTIE(self): + for fmt, extension in ( + ("xlsx", ".xlsx"), + ("csv", ".csv"), + ("json", ".json"), + ("xml", ".xml"), + ("xls", ".xlsx"), + ("access", ".xlsx"), + ): + with self.subTest(fmt=fmt): + self._repondre("") + rendu = self.menu._transform_select_destination("s", fmt) + self.assertTrue(rendu.endswith(extension), rendu) + + def test_garder_les_macros_nomme_la_copie_xlsm(self): + """Excel lie l'extension au contenu et refuse d'ouvrir un .xlsx + qui porte un projet VBA : la copie était juste et n'ouvrait pas.""" + self._repondre("") + rendu = self.menu._transform_select_destination( + "s.xlsm", "xlsx", macros=True + ) + self.assertTrue(rendu.endswith(".xlsm"), rendu) + self.assertIn(".xlsm", self.sortie.getvalue()) + + def test_les_macros_ne_changent_rien_hors_xlsx(self): + self._repondre("") + rendu = self.menu._transform_select_destination( + "s.csv", "csv", macros=True + ) + self.assertTrue(rendu.endswith(".csv"), rendu) + + def test_un_chemin_tape_est_pris_et_developpe(self): + self._repondre("~/copie.xlsx") + self.assertEqual( + self.menu._transform_select_destination("s", "xlsx"), + os.path.expanduser("~/copie.xlsx"), + ) + + def test_zero_renonce(self): + self._repondre("0") + self.assertIsNone(self.menu._transform_select_destination("s", "xlsx")) + + +class TestMenuEcrasement(unittest.TestCase): + """Le nom tapé EN ENTIER, comme le reste du dépôt l'exige.""" + + def setUp(self): + self.menu = _MenuBouchon() + 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) + vrai_input = builtins.input + self.addCleanup(setattr, builtins, "input", vrai_input) + self.existant = os.path.join(self.base, "copie.xlsx") + with open(self.existant, "w", encoding="utf-8") as flux: + flux.write("x") + + def test_rien_a_ecraser_ne_demande_rien(self): + def refuse(invite=""): + raise AssertionError("aucune question ne devait être posée") + + builtins.input = refuse + absent = os.path.join(self.base, "neuf.xlsx") + self.assertTrue(self.menu._transform_confirm_overwrite([absent])) + + def test_le_nom_exact_autorise(self): + builtins.input = lambda invite="": "copie.xlsx" + self.assertTrue( + self.menu._transform_confirm_overwrite([self.existant]) + ) + + def test_un_nom_approchant_refuse(self): + for reponse in ("copie", "copie.xls", "o", "", self.existant): + with self.subTest(reponse=reponse): + builtins.input = lambda invite="", r=reponse: r + self.assertFalse( + self.menu._transform_confirm_overwrite([self.existant]) + ) + + def test_les_cibles_existantes_sont_TOUTES_nommees(self): + second = os.path.join(self.base, "autre.csv") + with open(second, "w", encoding="utf-8") as flux: + flux.write("y") + builtins.input = lambda invite="": "copie.xlsx" + self.menu._transform_confirm_overwrite([self.existant, second, None]) + rendu = self.sortie.getvalue() + self.assertIn("copie.xlsx", rendu) + self.assertIn("autre.csv", rendu) + + +class TestMenuInventaire(unittest.TestCase): + """Ce que l'outil a produit se reconnaît à son NOM. + + Le navigateur ouvre son parcours dans ce même répertoire : ce qui s'y + trouve n'est pas toujours une copie produite ici, et l'effacement en + bloc l'emportait aussi. + """ + + def setUp(self): + self.menu = _MenuBouchon() + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + vrai = _tm().SORTIE_PAR_DEFAUT + _tm().SORTIE_PAR_DEFAUT = self.base + self.addCleanup(setattr, _tm(), "SORTIE_PAR_DEFAUT", vrai) + + def _poser(self, nom, contenu="x"): + chemin = os.path.join(self.base, nom) + with open(chemin, "w", encoding="utf-8") as flux: + flux.write(contenu) + return chemin + + def test_les_copies_et_les_tables_sont_dites_produites(self): + self._poser("20260101-000000.anon.xlsx") + self._poser("20260101-000000.table.json") + produites, etrangeres = self.menu._transform_inventaire() + self.assertEqual( + [n for n, _ in produites], + ["20260101-000000.anon.xlsx", "20260101-000000.table.json"], + ) + self.assertEqual(etrangeres, []) + + def test_un_fichier_depose_par_quelqu_un_d_autre_est_ETRANGER(self): + """Il n'est pas à effacer : le parcours ouvre ici.""" + self._poser("export_du_client.xlsx") + produites, etrangeres = self.menu._transform_inventaire() + self.assertEqual(produites, []) + self.assertEqual([n for n, _ in etrangeres], ["export_du_client.xlsx"]) + + def test_un_repertoire_de_conversion_pese_la_somme_de_ses_fichiers(self): + """Une source à plusieurs feuilles convertie en csv écrit un + RÉPERTOIRE. Il était compté à la taille de son inode.""" + dossier = os.path.join(self.base, "20260101.anon.csv") + os.makedirs(os.path.join(dossier, "sous")) + for chemin, contenu in ( + (os.path.join(dossier, "a.csv"), "12345"), + (os.path.join(dossier, "sous", "b.csv"), "678"), + ): + with open(chemin, "w", encoding="utf-8") as flux: + flux.write(contenu) + produites, _etrangeres = self.menu._transform_inventaire() + self.assertEqual(produites, [("20260101.anon.csv", 8)]) + + def test_un_repertoire_vide_pese_zero_et_reste_liste(self): + os.makedirs(os.path.join(self.base, "20260101.anon.csv")) + produites, _e = self.menu._transform_inventaire() + self.assertEqual(produites, [("20260101.anon.csv", 0)]) + + +class TestMenuFichierEntree(unittest.TestCase): + """Le parcours d'abord, la saisie en repli. + + La garde d'import est celle de `database_manager` : urwid peut + manquer, et sans elle « p » lèverait sur None. + """ + + def setUp(self): + self.menu = _MenuBouchon() + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.sortie = io.StringIO() + vrai_out = sys.stdout + sys.stdout = self.sortie + self.addCleanup(setattr, sys, "stdout", vrai_out) + vrai_input = builtins.input + self.addCleanup(setattr, builtins, "input", vrai_input) + # Sans navigateur, la saisie répond : c'est le chemin qu'on teste, + # et lancer urwid dans une suite unitaire n'a pas de sens. + vrai_nav = _tm().todo_file_browser + _tm().todo_file_browser = None + self.addCleanup(setattr, _tm(), "todo_file_browser", vrai_nav) + + def _repondre(self, reponse): + builtins.input = lambda invite="": reponse + + def test_un_fichier_ordinaire_est_pris(self): + chemin = os.path.join(self.base, "s.csv") + with open(chemin, "w", encoding="utf-8") as flux: + flux.write("a\n") + self._repondre(chemin) + self.assertEqual(self.menu._transform_select_file(), chemin) + + def test_un_repertoire_est_refuse(self): + """`--report` sur un répertoire ferait lever le moteur.""" + self._repondre(self.base) + self.assertIsNone(self.menu._transform_select_file()) + + def test_un_chemin_absent_est_refuse(self): + self._repondre(os.path.join(self.base, "absent.csv")) + self.assertIsNone(self.menu._transform_select_file()) + + def test_une_reponse_vide_ou_zero_renonce(self): + for reponse in ("", "0", " "): + with self.subTest(reponse=reponse): + self._repondre(reponse) + self.assertIsNone(self.menu._transform_select_file()) + + +class TestMenuRendus(unittest.TestCase): + """Le rapport et le bilan : ce que l'opérateur lit pour décider.""" + + def setUp(self): + self.menu = _MenuBouchon() + self.sortie = io.StringIO() + vrai = sys.stdout + sys.stdout = self.sortie + self.addCleanup(setattr, sys, "stdout", vrai) + + def test_le_rapport_nomme_le_fichier_sa_taille_et_son_format(self): + rendu = self.menu._transform_render_report( + {"chemin": "/tmp/s.csv", "taille": 42, "format": "csv"} + ) + self.assertIn("s.csv", rendu) + self.assertIn("42", rendu) + self.assertIn("csv", rendu) + + def test_une_divergence_entre_octets_et_extension_est_DITE(self): + """Un export d'ERP en HTML sous une extension .csv se lit comme + du HTML : le taire ferait consentir sur un format supposé.""" + rendu = self.menu._transform_render_report( + { + "chemin": "s.csv", + "taille": 1, + "format": "xml", + "divergence": True, + } + ) + self.assertIn( + todo_i18n.t("Contents do not match the extension: read as "), + rendu, + ) + + def test_l_encodage_et_le_delimiteur_disent_QUI_les_a_decides(self): + rendu = self.menu._transform_render_report( + { + "chemin": "s.csv", + "taille": 1, + "format": "csv", + "encodage": "cp1252", + "encodage_source": "chardet", + "delimiteur": ";", + "delimiteur_source": "sniffer", + } + ) + self.assertIn("cp1252", rendu) + self.assertIn(todo_i18n.t("chardet"), rendu) + self.assertIn(todo_i18n.t("sniffer"), rendu) + + def test_les_colonnes_sans_exemple_sont_COMPTEES_dans_le_rapport(self): + """Une colonne sans exemple se lit comme une colonne vide.""" + rendu = self.menu._transform_render_report( + { + "chemin": "s.csv", + "taille": 1, + "format": "csv", + "feuilles": [ + { + "nom": "F", + "lignes": 3, + "colonnes_n": 505, + "formules": 0, + "colonnes": [], + "exemples_manquants": 5, + } + ], + } + ) + self.assertIn(todo_i18n.t("column(s) without examples"), rendu) + self.assertIn("5", rendu) + + def test_le_bilan_nomme_CHAQUE_fichier_ecrit(self): + """Une conversion à plusieurs feuilles en écrit plusieurs.""" + self.menu._transform_render_bilan( + {"fichiers": ["/tmp/a.csv", "/tmp/b.csv"], "remplacees": 4}, + "/tmp/ignore", + ) + rendu = self.sortie.getvalue() + self.assertIn("/tmp/a.csv", rendu) + self.assertIn("/tmp/b.csv", rendu) + self.assertNotIn("ignore", rendu) + + def test_sans_liste_le_bilan_retombe_sur_la_destination(self): + self.menu._transform_render_bilan({"remplacees": 1}, "/tmp/seul.xlsx") + self.assertIn("/tmp/seul.xlsx", self.sortie.getvalue()) + + +class TestMenuEffacement(unittest.TestCase): + """L'effacement en bloc n'emporte QUE ce que l'outil a produit.""" + + def setUp(self): + self.menu = _MenuBouchon() + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + vrai_sortie = _tm().SORTIE_PAR_DEFAUT + _tm().SORTIE_PAR_DEFAUT = self.base + self.addCleanup(setattr, _tm(), "SORTIE_PAR_DEFAUT", vrai_sortie) + self.ecran = io.StringIO() + vrai_out = sys.stdout + sys.stdout = self.ecran + self.addCleanup(setattr, sys, "stdout", vrai_out) + vrai_input = builtins.input + self.addCleanup(setattr, builtins, "input", vrai_input) + self.copie = os.path.join(self.base, "20260101.anon.xlsx") + self.table = os.path.join(self.base, "20260101.table.json") + self.etranger = os.path.join(self.base, "export_du_client.xlsx") + for chemin in (self.copie, self.table, self.etranger): + with open(chemin, "w", encoding="utf-8") as flux: + flux.write("x") + self.dossier = os.path.join(self.base, "20260102.anon.csv") + os.makedirs(self.dossier) + with open(os.path.join(self.dossier, "a.csv"), "w") as flux: + flux.write("y") + + def test_oui_efface_les_copies_et_LAISSE_l_etranger(self): + builtins.input = lambda invite="": "o" + self.menu._transform_copies() + self.assertFalse(os.path.exists(self.copie)) + self.assertFalse(os.path.exists(self.table)) + self.assertFalse(os.path.exists(self.dossier)) + self.assertTrue(os.path.exists(self.etranger)) + + def test_non_n_efface_rien(self): + for reponse in ("n", "", "0", "x"): + with self.subTest(reponse=reponse): + builtins.input = lambda invite="", r=reponse: r + self.menu._transform_copies() + self.assertTrue(os.path.exists(self.copie)) + self.assertTrue(os.path.exists(self.dossier)) + + def test_l_etranger_est_NOMME_a_l_ecran(self): + """Le parcours ouvre dans ce répertoire : ce qui s'y trouve n'est + pas toujours une copie produite ici.""" + builtins.input = lambda invite="": "n" + self.menu._transform_copies() + rendu = self.ecran.getvalue() + self.assertIn("export_du_client.xlsx", rendu) + self.assertIn(todo_i18n.t("Not produced here, left alone:"), rendu) + + def test_un_repertoire_absent_ne_leve_pas(self): + _tm().SORTIE_PAR_DEFAUT = os.path.join(self.base, "absent") + + def refuse(invite=""): + raise AssertionError("rien à effacer, rien à demander") + + builtins.input = refuse + self.menu._transform_copies() + + def test_sans_copie_produite_aucune_question_n_est_posee(self): + os.remove(self.copie) + os.remove(self.table) + shutil.rmtree(self.dossier) + + def refuse(invite=""): + raise AssertionError("rien à effacer, rien à demander") + + builtins.input = refuse + self.menu._transform_copies() + self.assertTrue(os.path.exists(self.etranger)) + + +class TestMenuDerouleComplet(unittest.TestCase): + """L'ordre du dialogue EST la règle : rapport, questions, marche à + blanc, écriture. Une question posée avant de savoir ce qui sera touché + n'est pas un consentement. + + Le moteur est bouchonné : ce qu'on éprouve ici est le CÂBLAGE — quels + arguments partent, dans quel ordre, et ce qui arrête le déroulé. + """ + + def setUp(self): + self.menu = _MenuBouchon() + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.source = os.path.join(self.base, "s.csv") + with open(self.source, "w", encoding="utf-8") as flux: + flux.write("etiquette\naboulie\n") + self.ecran = io.StringIO() + vrai_out = sys.stdout + sys.stdout = self.ecran + self.addCleanup(setattr, sys, "stdout", vrai_out) + vrai_input = builtins.input + self.addCleanup(setattr, builtins, "input", vrai_input) + vrai_nav = _tm().todo_file_browser + _tm().todo_file_browser = None + self.addCleanup(setattr, _tm(), "todo_file_browser", vrai_nav) + # `ensure` installerait un venv : ici le format est du pur stdlib. + self.appels = [] + self.rendus = [] + self.menu._transform_run = self._run + # Comme ci-dessus : l'écran réel bloquerait la suite. + self.menu._transform_ecran = lambda *args: {} + # Le rapport est un VRAI rapport, non une main écrite : la forme + # que le rendu attend change avec le moteur, et une fixture à la + # main dériverait sans qu'un test le voie. + self.rapport = formats.report(self.source) + + def _run(self, arguments, fmt=None): + self.appels.append((list(arguments), fmt)) + return self.rendus.pop(0) if self.rendus else None + + def _dialogue(self, *reponses): + builtins.input = _Entrees(*reponses) + + def test_le_deroule_qui_aboutit_appelle_plan_PUIS_apply(self): + self.rendus = [ + self.rapport, + {"remplacees": 1, "fichiers": [os.path.join(self.base, "o.csv")]}, + {"remplacees": 1, "fichiers": [os.path.join(self.base, "o.csv")]}, + ] + self._dialogue( + self.source, # le fichier + "o", + "", + "", + "", + "", + "", + "", + "", + "", # les questions + os.path.join(self.base, "o.csv"), # la destination + "", # écrire ? défaut oui + ) + self.menu._transform_open_and_report() + etapes = [a[0][0] for a in self.appels] + self.assertEqual(etapes, ["--report", "--plan", "--apply"]) + + def test_un_arret_annonce_par_le_moteur_stoppe_avant_les_questions(self): + """Un `.xlsb` reconnu et illisible ici s'arrête là : poser les + douze questions pour finir sur un refus est une perte de temps.""" + self.rendus = [dict(self.rapport, arret="Nothing to do.")] + # L'arrêt vient du moteur : le rapport est complet, mais il dit + # que rien ne peut être écrit. + + posees = [] + + def refuse(invite=""): + posees.append(invite) + if len(posees) == 1: + return self.source + raise AssertionError("aucune question ne devait suivre l'arrêt") + + builtins.input = refuse + self.menu._transform_open_and_report() + self.assertEqual([a[0][0] for a in self.appels], ["--report"]) + + def test_refuser_l_apercu_n_appelle_PAS_apply(self): + self.rendus = [self.rapport, {"remplacees": 1}] + self._dialogue( + self.source, + "o", # anonymiser + "", # convertir : garder le format + "", # table du lot + "o", # options avancées + "", # colonnes intactes + "", # nombres + "", # calibre des chiffres + "", # texte + "", # en-têtes + "", # graine + os.path.join(self.base, "o.csv"), + "n", + ) + self.menu._transform_open_and_report() + self.assertEqual( + [a[0][0] for a in self.appels], ["--report", "--plan"] + ) + + def test_renoncer_aux_questions_n_appelle_ni_plan_ni_apply(self): + self.rendus = [self.rapport] + self._dialogue(self.source, "n") + self.menu._transform_open_and_report() + self.assertEqual([a[0][0] for a in self.appels], ["--report"]) + + def test_la_table_par_defaut_est_passee_au_moteur(self): + """Sans elle, un lot ne donne pas le même mot au même client.""" + self.rendus = [self.rapport, {"remplacees": 1}, {"remplacees": 1}] + self._dialogue( + self.source, + "o", # anonymiser + "", # convertir : garder le format + "", # table du lot + "o", # options avancées + "", # colonnes intactes + "", # nombres + "", # calibre des chiffres + "", # texte + "", # en-têtes + "", # graine + os.path.join(self.base, "o.csv"), + "", + ) + self.menu._transform_open_and_report() + plan = self.appels[1][0] + self.assertIn("--table", plan) + table = plan[plan.index("--table") + 1] + self.assertTrue(table.endswith(".table.json"), table) + self.assertTrue(table.startswith(_tm().SORTIE_PAR_DEFAUT), table) + + def test_l_ecrasement_est_confirme_sur_les_fichiers_du_PLAN(self): + """La marche à blanc dit les chemins réels ; confirmer sur la + seule destination manquait ceux d'une conversion par feuille.""" + deja = os.path.join(self.base, "deja.csv") + with open(deja, "w", encoding="utf-8") as flux: + flux.write("z") + self.rendus = [self.rapport, {"remplacees": 1, "fichiers": [deja]}] + self._dialogue( + self.source, + "o", # anonymiser + "", # convertir : garder le format + "", # table du lot + "o", # options avancées + "", # colonnes intactes + "", # nombres + "", # calibre des chiffres + "", # texte + "", # en-têtes + "", # graine + os.path.join(self.base, "o.csv"), + "", + "pas-le-bon-nom", + ) + self.menu._transform_open_and_report() + self.assertEqual( + [a[0][0] for a in self.appels], ["--report", "--plan"] + ) + self.assertIn( + todo_i18n.t("Name does not match, nothing was written."), + self.ecran.getvalue(), + ) + + def test_les_options_partent_en_json_analysable(self): + self.rendus = [self.rapport, {"remplacees": 1}, {"remplacees": 1}] + # Une source d'UNE feuille ne pose pas la question des feuilles. + self._dialogue( + self.source, + "o", # anonymiser + "json", # convertir + "", # table du lot — posée AVANT l'écran + "o", # options avancées + "id", # colonnes intactes + "", # nombres + "", # calibre des chiffres + "", # texte + "", # en-têtes + "7", # graine + os.path.join(self.base, "o.json"), + "", # écrire + ) + self.menu._transform_open_and_report() + plan = self.appels[1][0] + options = json.loads(plan[plan.index("--options") + 1]) + self.assertEqual(options["conversion"], "json") + self.assertEqual(options["colonnes_intactes"], ["id"]) + self.assertEqual(options["graine"], "7") + self.assertTrue(options["destination"].endswith("o.json")) + + +def _colonne( + etiquette, mini, maxi, distinctes, entiere=True, plancher=False, index=1 +): + return { + "index": index, + "etiquette": etiquette, + "type": "nombre", + "remplies": distinctes, + "distinctes": distinctes, + "min": mini, + "max": maxi, + "entiere": entiere, + "plancher": plancher, + } + + +def _rapport(colonnes, nom="F", format_lu="xlsx"): + return { + "format": format_lu, + "feuilles": [{"nom": nom, "colonnes": colonnes}], + "hors_cellules": {}, + } + + +class TestColonneSaturee(unittest.TestCase): + """Une colonne d'entiers pleine ressort PERMUTÉE. + + Le tirage est sans remise et reste dans l'étendue mesurée : avec + autant de valeurs distinctes que l'étendue compte d'entiers, + l'ensemble de sortie EST l'ensemble d'entrée. Ce n'est pas une fuite — + la permutation ne s'inverse pas sans la table — mais l'écran annonce + « N nombres remplacés » et une comparaison d'ensembles ne montrerait + rien. + """ + + OPTIONS = {"nombres": True, "colonnes_intactes": []} + + def _saturees(self, colonnes, **surcharges): + options = dict(self.OPTIONS, **surcharges) + return formats._colonnes_saturees(_rapport(colonnes), options) + + def test_une_etendue_pleine_est_signalee(self): + """830 valeurs distinctes dans 830 entiers : aucune liberté.""" + self.assertEqual( + self._saturees([_colonne("OrderID", 10248, 11077, 830)]), + [("F", "OrderID")], + ) + + def test_une_etendue_large_ne_l_est_pas(self): + self.assertEqual( + self._saturees([_colonne("montant", 1, 100000, 830)]), [] + ) + + def test_neuf_dixiemes_suffisent(self): + """La copie porte déjà presque le même ensemble.""" + self.assertTrue(self._saturees([_colonne("k", 1, 100, 90)])) + self.assertFalse(self._saturees([_colonne("k", 1, 100, 89)])) + + def test_une_colonne_decimale_n_est_jamais_saturee(self): + """Entre deux entiers, un décimal a une infinité de places.""" + self.assertEqual( + self._saturees([_colonne("taux", 1, 30, 30, entiere=False)]), + [], + ) + + def test_une_petite_colonne_pleine_ne_dit_rien_de_personne(self): + """Un drapeau à deux états, un mois sur douze : l'avertissement y + serait du bruit, et le bruit finit par se lire comme du fond.""" + for etendue in (2, 12, 19): + with self.subTest(etendue=etendue): + self.assertEqual( + self._saturees([_colonne("m", 1, etendue, etendue)]), [] + ) + self.assertTrue(self._saturees([_colonne("m", 1, 20, 20)])) + + def test_une_colonne_plancheiee_n_entre_pas(self): + """Elle n'est pas remplacée du tout, et `colonnes_ecartees` la + nomme déjà.""" + self.assertEqual( + self._saturees( + [_colonne("partner_id", 1, 830, 830, plancher=True)] + ), + [], + ) + + def test_une_colonne_laissee_intacte_n_entre_pas(self): + self.assertEqual( + self._saturees( + [_colonne("OrderID", 1, 830, 830)], + colonnes_intactes={"OrderID"}, + ), + [], + ) + + def test_sans_remplacement_des_nombres_rien_n_est_dit(self): + self.assertEqual( + self._saturees([_colonne("k", 1, 830, 830)], nombres=False), [] + ) + + def test_un_compte_au_PLAFOND_ne_conclut_rien(self): + """`_stats_colonnes` cesse de compter au-delà : le nombre ne dit + plus combien la colonne porte, et rien n'en découle.""" + plafond = formats.PLAFOND_DISTINCTES + self.assertEqual( + self._saturees([_colonne("k", 1, plafond, plafond)]), [] + ) + + def test_une_feuille_hors_selection_n_entre_pas(self): + rapport = _rapport([_colonne("k", 1, 830, 830)], nom="Achats") + options = dict(self.OPTIONS, feuilles=["Ventes"]) + self.assertEqual(formats._colonnes_saturees(rapport, options), []) + + def test_l_avertissement_est_dit_une_seule_fois(self): + colonnes = [ + _colonne("a", 1, 830, 830, index=1), + _colonne("b", 1, 830, 830, index=2), + ] + dits = formats._avertissements(_rapport(colonnes), self.OPTIONS) + avis = [d for d in dits if "saturated" in d] + self.assertEqual(len(avis), 1) + self.assertIn(avis[0], todo_i18n.TRANSLATIONS) + + +class TestAvertissementDesNomsDeFeuille(unittest.TestCase): + """Il ne vaut QUE pour le chemin qui les recopie tels quels. + + Toute conversion passe par un classeur neuf dont les onglets + reçoivent un nom de la table, et `.xls` comme Access n'ont pas de + graveur — leur copie repart par cette même conversion. Le dire quand + ce n'est pas vrai apprend à ne plus lire la liste, qui EST la surface + du consentement. + """ + + AVIS = "Sheet names are kept so formulas resolve; they may identify." + + def _dits(self, format_lu, conversion=""): + return formats._avertissements( + {"format": format_lu, "hors_cellules": {}, "feuilles": []}, + {"conversion": conversion}, + ) + + def test_un_xlsx_sans_conversion_les_garde(self): + self.assertIn(self.AVIS, self._dits("xlsx")) + self.assertIn(self.AVIS, self._dits("xlsx", "xlsx")) + + def test_une_conversion_les_anonymise(self): + for cible in ("csv", "json", "xml"): + with self.subTest(cible=cible): + self.assertNotIn(self.AVIS, self._dits("xlsx", cible)) + + def test_xls_et_access_n_ont_pas_de_graveur(self): + """Leur copie repart en .xlsx par la conversion, qui renomme.""" + for format_lu in ("xls", "access"): + with self.subTest(format_lu=format_lu): + self.assertNotIn(self.AVIS, self._dits(format_lu)) + + def test_les_plages_nommees_suivent_la_meme_condition(self): + avis = ( + "Range and table names are kept so formulas resolve;" + " they may hold identifying strings." + ) + garde = formats._avertissements( + { + "format": "xlsx", + "hors_cellules": {"plages_nommees": 2}, + "feuilles": [], + }, + {"conversion": ""}, + ) + convertit = formats._avertissements( + { + "format": "xlsx", + "hors_cellules": {"plages_nommees": 2}, + "feuilles": [], + }, + {"conversion": "csv"}, + ) + self.assertIn(avis, garde) + self.assertNotIn(avis, convertit) + + +class _NavigateurBouchon: + """Le navigateur de fichiers, réduit à son CONTRAT. + + Il appelle le rappel avec un chemin, puis sort de sa boucle — ce que + le vrai fait aussi, `exit_program()` suivant l'appel dans son code. + Le bouchonner à `None` sautait la branche entière, et c'est ainsi + qu'un rappel introuvable a atteint le premier usage de l'entrée. + """ + + def __init__(self, rendu): + self.rendu = rendu + self.appele_avec = None + + def FileBrowser(self, depart, rappel, open_dir=False): + self.depart = depart + self.open_dir = open_dir + navigateur = self + + class Fenetre: + def run_main_frame(self): + navigateur.appele_avec = navigateur.rendu + rappel(navigateur.rendu) + + return Fenetre() + + +class TestMenuNavigateurDeFichiers(unittest.TestCase): + """Le rappel que le navigateur appelle doit EXISTER sur la classe. + + Ce mixin fournit le sien : `TODO` nomme le sien `on_dir_selected`, et + le préfixé vit sur le gestionnaire de bases, qui n'est pas un mixin. + L'emprunter faisait lever `AttributeError` à l'ouverture du + navigateur — donc sur la première ligne du menu, au premier usage. + """ + + def setUp(self): + self.menu = _MenuBouchon() + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.ecran = io.StringIO() + vrai_out = sys.stdout + sys.stdout = self.ecran + self.addCleanup(setattr, sys, "stdout", vrai_out) + vrai_input = builtins.input + self.addCleanup(setattr, builtins, "input", vrai_input) + self.vrai_nav = _tm().todo_file_browser + self.addCleanup(setattr, _tm(), "todo_file_browser", self.vrai_nav) + + def _navigateur(self, rendu): + bouchon = _NavigateurBouchon(rendu) + _tm().todo_file_browser = bouchon + return bouchon + + def test_le_rappel_existe_sur_la_classe_d_accueil(self): + """La vraie classe, non le bouchon : c'est `TODO` qui reçoit le + mixin, et c'est là que le rappel manquait.""" + from script.todo.todo import TODO + + self.assertTrue(hasattr(TODO, "_on_dir_selected")) + hote = TODO.__new__(TODO) + hote._on_dir_selected("/tmp/choisi") + self.assertEqual(hote._dir_path, "/tmp/choisi") + + def test_un_fichier_choisi_au_navigateur_est_rendu(self): + chemin = os.path.join(self.base, "s.csv") + with open(chemin, "w", encoding="utf-8") as flux: + flux.write("a\n") + self._navigateur(chemin) + self.assertEqual(self.menu._transform_select_file(), chemin) + + def test_un_choix_qui_n_est_pas_un_fichier_retombe_sur_la_saisie(self): + """Le navigateur peut rendre un répertoire : la saisie tranche.""" + self._navigateur(self.base) + chemin = os.path.join(self.base, "s.csv") + with open(chemin, "w", encoding="utf-8") as flux: + flux.write("a\n") + builtins.input = lambda invite="": chemin + self.assertEqual(self.menu._transform_select_file(), chemin) + + def test_la_destination_passe_aussi_par_le_rappel(self): + """Second appel du navigateur, en mode répertoire.""" + bouchon = self._navigateur(self.base) + builtins.input = lambda invite="": "p" + rendu = self.menu._transform_select_destination("s.xlsx", "xlsx") + self.assertTrue(bouchon.open_dir) + self.assertEqual(os.path.dirname(rendu), self.base) + self.assertTrue(rendu.endswith(".xlsx"), rendu) + + def test_un_navigateur_absent_ne_leve_pas(self): + """urwid peut manquer : « p » lèverait alors sur None.""" + _tm().todo_file_browser = None + builtins.input = lambda invite="": "p" + self.assertTrue( + self.menu._transform_select_destination("s.xlsx", "xlsx") + ) + + +class TestGardeeSousSesDeuxFormes(unittest.TestCase): + """Le filet cherche la valeur telle que la TABLE la porte. + + Un en-tête qui finit par une espace est annoncé remplacé sous sa forme + brute — là où il est en portée — et n'était excusé que sous sa forme + dépouillée là où il est gardé. Un fichier ordinaire dont une colonne + s'intitule « Montant » avec une espace finale se faisait refuser. + """ + + def test_les_deux_formes_sont_notees(self): + gardees = {} + formats._noter_gardee(gardees, "F", "aboulie ") + self.assertEqual(gardees["F"], {"aboulie ", "aboulie"}) + + def test_une_valeur_sans_espace_n_est_notee_qu_une_fois(self): + gardees = {} + formats._noter_gardee(gardees, "F", "aboulie") + self.assertEqual(gardees["F"], {"aboulie"}) + + def test_le_seuil_du_filet_s_applique_aux_deux(self): + """Sous le seuil, le filet ne cherche pas : le noter serait + tolérer une chaîne qu'il n'examine jamais.""" + gardees = {} + formats._noter_gardee(gardees, "F", " ab ") + self.assertEqual(gardees.get("F"), {" ab "}) + + def test_une_valeur_non_texte_traverse_sans_lever(self): + gardees = {} + formats._noter_gardee(gardees, "F", 12345) + formats._noter_gardee(gardees, "F", None) + self.assertEqual(gardees.get("F"), {"12345"}) + + +class TestNomsDeColonneDeTableau(unittest.TestCase): + """OOXML exige que `tableColumn.name` égale sa cellule d'en-tête, et + que les noms d'un tableau soient DISTINCTS. + + Deux manquements, chacun grave à sa façon : un nom qu'aucune cellule + ne porte laissait sortir le texte d'origine SANS que le filet puisse + le voir — il n'a jamais été lu d'une cellule, donc jamais annoncé — et + un en-tête de deux lignes donnait deux fois le même nom, ce qu'Excel + annonce comme un fichier à réparer. + """ + + # La fonction ne touche que des ATTRIBUTS : un bouchon suffit, et le + # test tourne alors sous l'interpréteur du CLI, qui n'a pas openpyxl. + # Le faire par un vrai classeur l'aurait exclu de la suite. + @staticmethod + def _classeur(lignes, hauteur_entete=1, noms=("a", "b")): + class Colonne: + def __init__(self, nom): + self.name = nom + self.totalsRowLabel = None + + class Cellule: + def __init__(self, ligne, colonne, valeur): + self.row = ligne + self.column = colonne + self.value = valeur + + class Tableau: + def __init__(self): + self.ref = "A1:B%d" % len(lignes) + self.headerRowCount = hauteur_entete + self.tableColumns = [Colonne(n) for n in noms] + + class Onglet: + tables = {} + + def __getitem__(self, adresse): + assert adresse == "A1", adresse + return Cellule(1, 1, None) + + def cell(self, row, column): + ligne = lignes[row - 1] if row <= len(lignes) else [] + valeur = ligne[column - 1] if column <= len(ligne) else None + return Cellule(row, column, valeur) + + class Classeur: + def __init__(self, onglet): + self.worksheets = [onglet] + + onglet = Onglet() + tableau = Tableau() + onglet.tables = {"T1": tableau} + return Classeur(onglet), onglet, tableau + + def _noms(self, *args, **kwargs): + classeur, _onglet, tableau = self._classeur(*args, **kwargs) + formats._resynchroniser_tableaux(classeur) + return [c.name for c in tableau.tableColumns] + + def test_le_nom_suit_la_cellule_d_en_tete(self): + self.assertEqual( + self._noms([["acai", "acanthe"], [1, 2]], noms=("vieux", "vieil")), + ["acai", "acanthe"], + ) + + def test_un_en_tete_de_DEUX_lignes_prend_la_derniere(self): + """La ligne de catégorie se répète souvent d'une colonne à + l'autre : la prendre donnait deux noms identiques.""" + self.assertEqual( + self._noms( + [["cat", "cat"], ["acai", "acanthe"], [1, 2]], + hauteur_entete=2, + noms=("vieux", "vieil"), + ), + ["acai", "acanthe"], + ) + + def test_un_nom_sans_cellule_est_remplace_positionnellement(self): + """Il sortait tel quel dans `xl/tables/`, hors d'atteinte du + filet.""" + noms = self._noms( + [[None, "acanthe"], [1, 2]], noms=("aboulie", "acai") + ) + self.assertEqual(noms, ["colonne_1", "acanthe"]) + self.assertNotIn("aboulie", noms) + + def test_une_cellule_non_texte_ne_nomme_pas_une_colonne(self): + noms = self._noms([[2024, "acanthe"], [1, 2]], noms=("aboulie", "x")) + self.assertEqual(noms[0], "colonne_1") + + def test_deux_en_tetes_IDENTIQUES_se_distinguent(self): + """Sans quoi le tableau porte deux fois le même nom.""" + noms = self._noms([["acai", "acai"], [1, 2]], noms=("v", "w")) + self.assertEqual(len(set(noms)), 2, noms) + self.assertEqual(noms[0], "acai") + + def test_le_libelle_de_ligne_de_total_part(self): + """Il n'est jamais une cellule et survivait quelles que soient + les options.""" + classeur, _o, tableau = self._classeur([["acai", "acanthe"], [1, 2]]) + tableau.tableColumns[0].totalsRowLabel = "aboulie" + formats._resynchroniser_tableaux(classeur) + self.assertIsNone(tableau.tableColumns[0].totalsRowLabel) + + +class TestLitterauxDeFormule(unittest.TestCase): + """Ce qu'une formule PRÉSERVE, et que le filet doit donc excuser. + + Les guillemets ne suffisent pas : une référence structurée de tableau + porte le nom de colonne entre CROCHETS et sans guillemets. Toutes les + cellules d'une colonne calculée partagent le même texte, si bien que + le bloc toléré ne l'excuse qu'une fois là où la copie le porte deux + fois — dans la feuille et dans `xl/tables/`. + """ + + def test_une_reference_structuree_rend_le_nom_de_colonne(self): + rendu = set( + formats._litteraux_de_formule("=T1[[#This Row],[Montant]]*S$5") + ) + self.assertIn("Montant", rendu) + self.assertIn("#This Row", rendu) + + def test_un_littéral_entre_guillemets_reste_rendu(self): + self.assertIn( + "aboulie", + formats._litteraux_de_formule('=IF(A1="aboulie",1,0)'), + ) + + def test_le_texte_ENTIER_est_rendu_aussi(self): + """La tolérance d'origine : le filet compte les occurrences dans + le bloc joint, et le texte complet y participe.""" + self.assertIn("=A1+1", formats._litteraux_de_formule("=A1+1")) + + def test_une_valeur_texte_NUE_ne_tolère_rien(self): + """La garde sur « = » porte tout le filet : sans elle, la + fonction étant appelée sur chaque cellule, toute valeur texte + serait tolérée et plus rien ne serait refusé.""" + for valeur in ("Montant", "aboulie", "", " ", "A1+1"): + with self.subTest(valeur=valeur): + self.assertEqual( + set(formats._litteraux_de_formule(valeur)), set() + ) + + def test_un_nom_a_espace_finale_garde_son_espace(self): + """Le nom réel est celui que la formule porte, espaces compris : + le dépouiller ne l'aurait jamais fait correspondre.""" + self.assertIn( + "expected 1 ", + formats._litteraux_de_formule("=T[[#This Row],[expected 1 ]]"), + ) + + def test_l_apostrophe_d_echappement_est_retirée(self): + """Excel échappe `[`, `]`, `#` et l'apostrophe par une + apostrophe : le nom réel est la forme déséchappée.""" + rendu = formats._litteraux_de_formule("=T[[#This Row],[a'#b]]") + self.assertIn("a#b", rendu) + + def test_une_formule_matricielle_n_est_pas_une_chaine(self): + """Un objet à `text` : le tester par `startswith` la faisait + passer inaperçue.""" + + class Matricielle: + text = "=T[[#This Row],[acanthe]]" + + self.assertIn("acanthe", formats._litteraux_de_formule(Matricielle())) + + def test_sans_egal_pour_un_texte_que_l_appelant_SAIT_etre_une_formule( + self, + ): + """OOXML omet le « = » dans `calculatedColumnFormula`. Le drapeau + est réservé aux appelants qui lisent une partie de FORMULE : le + passer sur une cellule rouvrirait le trou que la garde ferme.""" + self.assertIn( + "acanthe", + formats._litteraux_de_formule( + "T[[#This Row],[acanthe]]", sans_egal=True + ), + ) + self.assertEqual( + set(formats._litteraux_de_formule("T[[#This Row],[acanthe]]")), + set(), + ) + + def test_ni_none_ni_un_nombre_ne_lèvent(self): + for valeur in (None, 12345, 3.5, True): + with self.subTest(valeur=valeur): + self.assertEqual( + set(formats._litteraux_de_formule(valeur)), set() + ) + + +class TestValeursGardeesDesFormules(unittest.TestCase): + """La collecte doit appeler la fonction là où les formules vivent. + + Deux endroits : la grille, et la formule PROPRE d'une colonne de + tableau — celle-là vit dans `xl/tables/`, ne passe par aucune cellule, + et son nom de colonne n'était excusé par personne. + """ + + @staticmethod + def _classeur(valeurs_de_cellule=(), formule_de_colonne=None): + """Un bouchon : la fonction ne lit que des attributs, si bien que + le test tourne sous l'interpréteur du CLI, sans openpyxl.""" + + class Cellule: + def __init__(self, valeur): + self.value = valeur + + class Colonne: + def __init__(self, formule): + self.calculatedColumnFormula = formule + self.totalsRowFormula = None + + class Tableau: + def __init__(self, formule): + self.tableColumns = [Colonne(formule)] + + class Noms(dict): + pass + + class Onglet: + title = "T" + defined_names = Noms() + + def __init__(self): + self.tables = ( + {"T1": Tableau(formule_de_colonne)} + if formule_de_colonne is not None + else {} + ) + + def iter_rows(self): + yield [Cellule(v) for v in valeurs_de_cellule] + + class Classeur: + defined_names = Noms() + + def __init__(self, onglet): + self.worksheets = [onglet] + + return Classeur(Onglet()) + + def _gardees(self, **kwargs): + return formats._valeurs_gardees(self._classeur(**kwargs), [], "xlsx") + + def test_une_formule_de_CELLULE_est_dépouillée(self): + gardees = self._gardees( + valeurs_de_cellule=("=T1[[#This Row],[Montant]]*2",) + ) + self.assertIn("Montant", gardees) + + def test_une_formule_de_COLONNE_de_tableau_aussi(self): + """Elle vit dans `xl/tables/` et ne passe par aucune cellule.""" + + class Formule: + text = "T1[[#This Row],[acanthe]]*2" + + gardees = self._gardees(formule_de_colonne=Formule()) + self.assertIn("acanthe", gardees) + + def test_une_valeur_texte_de_cellule_n_est_PAS_tolérée(self): + """Sinon le filet ne refuserait plus rien.""" + gardees = self._gardees(valeurs_de_cellule=("aboulie", "acai")) + self.assertNotIn("aboulie", gardees) + self.assertNotIn("acai", gardees) + + def test_un_classeur_sans_tableau_ne_lève_pas(self): + self.assertIsInstance(self._gardees(), set) + + +def _corps(hauteur=8): + """Un corps de tableau réaliste : trois formes de colonne distinctes.""" + return [ + ["ZK%06d" % (204817 + i), 501 + i, 100.5 + i] for i in range(hauteur) + ] + + +class TestLigneDeChamps(unittest.TestCase): + """La ligne d'en-tête est MESURÉE, non présumée. + + La présumer en ligne 1 est faux dans les deux sens : un rapport dont + A1 porte un titre a son en-tête plus bas, et une feuille sans en-tête + voyait sa première ligne de DONNÉES recopiée en clair. + """ + + ENTETE = ["N° facture", "N° magasin", "Montant"] + + def test_un_en_tete_en_ligne_1_est_trouve(self): + empan, champs = formats.lignes_entete([self.ENTETE] + _corps()) + self.assertEqual(empan, {1}) + self.assertEqual(champs, 1) + + def test_un_titre_en_A1_repousse_l_en_tete(self): + """Le cas qui motive la mesure : la ligne 1 n'est pas l'en-tête.""" + lignes = [ + ["Rapport annuel", None, None], + [None, None, None], + self.ENTETE, + ] + _corps() + empan, champs = formats.lignes_entete(lignes) + self.assertEqual(champs, 3) + self.assertEqual(empan, {1, 2, 3}) + + def test_une_feuille_SANS_en_tete_n_en_invente_pas(self): + """Sa ligne 1 est de la donnée : la garder la recopiait en clair.""" + empan, champs = formats.lignes_entete(_corps()) + self.assertEqual(empan, set()) + self.assertIsNone(champs) + + def test_une_ligne_de_categorie_ne_nomme_pas_les_colonnes(self): + """Elle répète un mot sur plusieurs colonnes : la retenir donnait + deux colonnes de même nom, ce qu'OOXML refuse.""" + lignes = [["Bloc", "Bloc", "Bloc"], self.ENTETE] + _corps() + empan, champs = formats.lignes_entete(lignes) + self.assertEqual(champs, 2) + self.assertEqual(empan, {1, 2}) + + def test_l_empan_s_arrete_a_la_premiere_ligne_de_donnees(self): + """Ce qui surmonte la ligne de champs sans être des données est de + la mise en page ; une ligne de données borne la remontée.""" + lignes = [self.ENTETE] + _corps() + empan, _champs = formats.lignes_entete(lignes) + self.assertEqual(empan, {1}) + + def test_au_dela_des_lignes_sondees_on_ne_cherche_plus(self): + """Un en-tête plus bas n'est pas un tableau, c'est une mise en + page — et l'opérateur corrige mieux qu'une mesure.""" + bourrage = [[None, None, None]] * formats.LIGNES_SONDEES + empan, champs = formats.lignes_entete( + bourrage + [self.ENTETE] + _corps() + ) + self.assertEqual(empan, set()) + self.assertIsNone(champs) + + def test_une_feuille_vide_ou_d_une_ligne_ne_leve_pas(self): + for lignes in ([], [self.ENTETE], [[]]): + with self.subTest(lignes=lignes): + self.assertEqual(formats.lignes_entete(lignes), (set(), None)) + + +class TestSignauxEnLot(unittest.TestCase): + """Le lot rend EXACTEMENT ce que le rang par rang rendait. + + C'est la propriété qui autorise le raccourci : `corps(rang)` est + `corps(rang + 1)` plus une ligne, donc descendre les rangs en + AJOUTANT une ligne à l'état par colonne remplace vingt balayages de la + feuille par un. Sans ce test, une retouche à l'une des deux voies + ferait dériver les verdicts sans que rien ne le dise. + """ + + def _comparer(self, lignes): + lot = formats._signaux_par_rang(lignes, formats.LIGNES_SONDEES) + attendus = {} + for rang in range(1, min(formats.LIGNES_SONDEES, len(lignes)) + 1): + attendus[rang] = ( + formats._signaux_entete(lignes, rang), + formats._accord_de_forme(lignes, rang), + ) + self.assertEqual(sorted(lot), sorted(attendus)) + for rang, (signaux, accord) in attendus.items(): + with self.subTest(rang=rang): + self.assertEqual(lot[rang][0], signaux) + if accord is None: + self.assertIsNone(lot[rang][1]) + else: + self.assertAlmostEqual(lot[rang][1], accord, places=12) + + ENTETE = ["N° facture", "N° magasin", "Montant"] + + def test_les_cas_nommes(self): + cas = { + "en-tête en ligne 1": [self.ENTETE] + _corps(), + "titre en A1": [ + ["Rapport annuel", None, None], + [None, None, None], + self.ENTETE, + ] + + _corps(), + "sans en-tête": _corps(), + "ligne de catégorie": [["Bloc", "Bloc", "Bloc"], self.ENTETE] + + _corps(), + "feuille courte": [ + ["Client%d" % rang, "Ville%d" % rang, 100 + rang] + for rang in range(12) + ], + "une seule ligne": [self.ENTETE], + "dents de scie": [["a"], ["b", "c", "d"], ["e", "f"], ["g"]], + "lignes vides": [[], [], []], + "vide": [], + } + for nom, lignes in cas.items(): + with self.subTest(cas=nom): + self._comparer(lignes) + + def test_sur_quatre_cents_grilles_tirees(self): + """Les cas nommés couvrent ce qu'on a pensé ; le tirage couvre le + reste — colonnes à trous, types mêlés, feuilles d'une ligne.""" + rng = random.Random(11) + for _ in range(400): + lignes = [] + for _ in range(rng.randint(1, 14)): + ligne = [] + for _ in range(rng.randint(1, 6)): + genre = rng.choice("nsSvdb") + if genre == "n": + ligne.append(rng.randint(-50, 5000)) + elif genre == "s": + ligne.append( + rng.choice(("Aubel", "Bruant", "Cerdan", "nom")) + ) + elif genre == "S": + ligne.append("ZK%05d" % rng.randint(1, 300)) + elif genre == "v": + ligne.append(None) + elif genre == "d": + ligne.append("2011-03-%02d" % rng.randint(1, 28)) + else: + ligne.append(rng.choice((True, False))) + lignes.append(ligne) + self._comparer(lignes) + + def test_la_dominante_de_forme_ne_depend_pas_de_l_ordre(self): + """`most_common` tranche une égalité par l'ordre d'INSERTION : + deux agrégations du même corps donnaient deux dominantes, donc + deux verdicts.""" + import collections + + premier = collections.Counter({"a+": 2, "9+": 2}) + second = collections.Counter() + second["9+"] = 2 + second["a+"] = 2 + self.assertEqual( + formats._forme_dominante(premier), + formats._forme_dominante(second), + ) + + def test_un_compteur_vide_n_a_pas_de_dominante(self): + import collections + + self.assertIsNone(formats._forme_dominante(collections.Counter())) + + +class TestFormeDeValeur(unittest.TestCase): + """La signature de forme : ce qui sépare un nom de champ d'une donnée + là où le TYPE ne dit rien, les deux étant du texte.""" + + def test_deux_valeurs_du_meme_moule_ont_une_seule_forme(self): + self.assertEqual( + formats._forme_de_valeur("ZK204817"), + formats._forme_de_valeur("MPQ204818"), + ) + + def test_les_longueurs_ne_distinguent_pas(self): + """Les répétitions sont écrasées : « 99999 » et « 999999 » sont + une forme, sinon chaque longueur ferait une forme à part.""" + self.assertEqual( + formats._forme_de_valeur(12345), + formats._forme_de_valeur(1234567), + ) + + def test_un_nom_de_champ_se_distingue_de_ses_donnees(self): + self.assertNotEqual( + formats._forme_de_valeur("N° facture"), + formats._forme_de_valeur("ZK204817"), + ) + + def test_une_date_et_un_nombre_ne_se_confondent_pas(self): + self.assertNotEqual( + formats._forme_de_valeur("2011-03-09"), + formats._forme_de_valeur(20110309), + ) + + def test_none_rend_la_forme_vide(self): + self.assertEqual(formats._forme_de_valeur(None), "") + + def test_l_accord_est_HAUT_sur_une_ligne_de_donnees(self): + """C'est le sens du signal : ressembler à ses données, c'en être.""" + lignes = _corps() + self.assertGreaterEqual(formats._accord_de_forme(lignes, 1), 0.9) + + def test_l_accord_est_BAS_sur_une_ligne_de_champs(self): + lignes = [["N° facture", "N° magasin", "Montant"]] + _corps() + self.assertLessEqual( + formats._accord_de_forme(lignes, 1), + formats.ACCORD_DE_DONNEE, + ) + + def test_sans_corps_sous_la_ligne_il_n_y_a_pas_de_verdict(self): + """Rien à comparer n'est pas « c'est un en-tête ».""" + self.assertIsNone(formats._accord_de_forme([["a", "b"]], 1)) + self.assertIsNone(formats._signaux_entete([["a", "b"]], 1)) + + +class TestSignalHorsColonne(unittest.TestCase): + """`hors_colonne` n'a de pouvoir que sur une colonne à VOCABULAIRE. + + La régression qu'il ferme : sur une grille tout-texte aux valeurs + toutes distinctes, une ligne de DONNÉES est absente du reste de sa + colonne exactement autant qu'un libellé l'est. Rapporté à la largeur, + le signal vaut 1,0 pour n'importe quelle ligne, et une première ligne + de données ponctuée autrement que son corps passe pour un en-tête — + donc sort en clair dans la copie. + """ + + # Trois colonnes tout-texte, toutes valeurs distinctes, sans en-tête. + # La ligne 1 est ponctuée au tiret là où le corps l'est à l'espace : + # c'est ce qui met son accord de forme à zéro. + SANS_ENTETE = [["Aubel-Nord", "Piece-A1", "Lot-B1"]] + [ + ["Bureau %d" % i, "Piece %d" % i, "Lot %d" % i] for i in range(1, 9) + ] + + # Les mêmes colonnes, mais à vocabulaire : des valeurs s'y répètent. + ENTETE = ["N° ville", "Statut du dossier", "Categorie"] + CORPS = [ + ["Aubel", "ouvert", "papeterie"], + ["Bruant", "ferme", "papeterie"], + ["Aubel", "ouvert", "outillage"], + ["Bruant", "ferme", "outillage"], + ["Aubel", "ouvert", "papeterie"], + ["Bruant", "ferme", "outillage"], + ] + + def test_sur_une_colonne_tout_distincte_le_signal_se_TAIT(self): + """0,0 dit « ce signal ne dit rien ici », non « c'est un en-tête ». + + Le dénominateur compte les colonnes où le signal porte quelque + chose. Aucune n'en a : il vaut zéro et laisse `accord` trancher + seul, ce qui est le comportement voulu. + """ + signaux = formats._signaux_entete(self.SANS_ENTETE, 1) + self.assertEqual(signaux["hors_colonne"], 0.0) + self.assertEqual(signaux["compares"], 3) + + def test_une_ligne_de_donnees_ainsi_ponctuee_n_est_PAS_un_en_tete(self): + """Le bout du fil : sans le dénominateur propre au signal, + l'empan garde cette ligne et la copie la sort en clair.""" + self.assertEqual( + formats.lignes_entete(self.SANS_ENTETE), (set(), None) + ) + + def test_sur_une_colonne_a_vocabulaire_le_signal_PARLE(self): + """Et il porte seul : le contraste est muet sur du tout-texte.""" + lignes = [self.ENTETE] + self.CORPS + signaux = formats._signaux_entete(lignes, 1) + self.assertEqual(signaux["contraste"], 0.0) + self.assertGreaterEqual( + signaux["hors_colonne"], formats.HORS_COLONNE_MINIMAL + ) + self.assertEqual(formats.lignes_entete(lignes), ({1}, 1)) + + def test_la_meme_table_sans_son_en_tete_n_en_invente_pas(self): + signaux = formats._signaux_entete(self.CORPS, 1) + self.assertEqual(signaux["hors_colonne"], 0.0) + self.assertEqual(formats.lignes_entete(self.CORPS), (set(), None)) + + +class TestCeQuiEstDeLaDonnee(unittest.TestCase): + """`_est_de_la_donnee` borne la remontée de l'empan : deux conditions, + et les deux sont nécessaires.""" + + ENTETE = ["N° ville", "N° magasin", "Montant"] + CORPS = [["Aubel", 501 + i, 100.5 + i] for i in range(8)] + + def test_la_forme_seule_ne_suffit_pas(self): + """Un titre seul en A1 partage la forme de la colonne de mots + qu'il surmonte ; sans la condition de remplissage, l'extension + s'arrêtait dessus et le mettait en PORTÉE.""" + lignes = [["Rapport", None, None], self.ENTETE] + self.CORPS + self.assertEqual(formats._accord_de_forme(lignes, 1), 1.0) + self.assertLess( + formats._signaux_entete(lignes, 1)["rempli"], + formats.REMPLISSAGE_MINIMAL, + ) + self.assertFalse(formats._est_de_la_donnee(lignes, 1)) + self.assertEqual(formats.lignes_entete(lignes), ({1, 2}, 2)) + + def test_le_remplissage_seul_ne_suffit_pas(self): + """Une ligne de catégorie couvre la largeur sans être des + données : sa forme ne s'accorde pas à celle du corps.""" + lignes = [["Bloc", "Bloc", "Bloc"], self.ENTETE] + self.CORPS + signaux = formats._signaux_entete(lignes, 1) + self.assertGreaterEqual(signaux["rempli"], formats.REMPLISSAGE_MINIMAL) + self.assertLess(formats._accord_de_forme(lignes, 1), 0.5) + self.assertFalse(formats._est_de_la_donnee(lignes, 1)) + self.assertEqual(formats.lignes_entete(lignes), ({1, 2}, 2)) + + def test_une_donnee_au_dessus_annule_le_candidat(self): + """La remontée bornée par une DONNÉE place le candidat au milieu + d'elle : un en-tête ne se trouve pas sous des enregistrements. + + Sur une feuille courte, le corps sous le rang sondé fond jusqu'à + trois lignes et la forme dominante y bascule — la ligne se + distingue alors de ce qui reste sans rien nommer, et sortait en + clair au milieu des données. Le verdict est donc « pas d'en-tête », + ce qui ANONYMISE, et l'écran corrige. + """ + lignes = [["Bruant", 599, 199.5], self.ENTETE] + self.CORPS + self.assertTrue(formats._est_de_la_donnee(lignes, 1)) + self.assertEqual(formats.lignes_entete(lignes), (set(), None)) + + def test_de_la_MISE_EN_PAGE_au_dessus_ne_l_annule_pas(self): + """L'empan atteint la ligne 1 en la traversant : c'est la + différence que `_est_de_la_donnee` sert à faire.""" + lignes = [["Rapport", None, None], self.ENTETE] + self.CORPS + self.assertFalse(formats._est_de_la_donnee(lignes, 1)) + self.assertEqual(formats.lignes_entete(lignes), ({1, 2}, 2)) + + def test_sans_corps_sous_la_ligne_ce_n_est_pas_de_la_donnee(self): + """Rien à comparer n'est pas un verdict : le refus fait entrer la + ligne dans l'empan, du côté qui ne fait pas sortir de donnée.""" + self.assertFalse(formats._est_de_la_donnee([self.ENTETE], 1)) + + +class TestEtiquettesDeLaLigneDeChamps(unittest.TestCase): + """`Feuille.etiquettes` suit la ligne de champs, non la ligne 1.""" + + def test_les_etiquettes_viennent_de_la_ligne_de_champs(self): + feuille = formats.Feuille( + "F", [["titre", None], ["nom", "montant"], ["x", 1]] + ) + feuille.ligne_champs = 2 + self.assertEqual(feuille.etiquettes, ["nom", "montant"]) + + def test_sans_ligne_de_champs_il_n_y_a_pas_d_etiquette(self): + """Le plancher répond alors par l'INDEX : les prendre sur une + ligne de données faisait planchéier au hasard.""" + feuille = formats.Feuille("F", [["x", 1], ["y", 2]]) + feuille.ligne_champs = None + self.assertEqual(feuille.etiquettes, []) + + def test_la_ligne_1_reste_le_defaut(self): + """Les lecteurs qui FABRIQUENT leur ligne 1 la connaissent + d'avance : Access, JSON et XML n'ont rien à mesurer.""" + feuille = formats.Feuille("F", [["cle", "valeur"], ["a", 1]]) + self.assertEqual(feuille.ligne_champs, 1) + self.assertEqual(feuille.etiquettes, ["cle", "valeur"]) + + def test_une_ligne_de_champs_hors_des_lignes_ne_leve_pas(self): + feuille = formats.Feuille("F", [["a"]]) + feuille.ligne_champs = 9 + self.assertEqual(feuille.etiquettes, []) + + +class TestUniciteDesClesI18n(unittest.TestCase): + """Une clé dupliquée dans un littéral de dict Python écrase la + précédente, sans erreur ni avertissement. + + Les deux entrées peuvent être identiques aujourd'hui : le jour où l'on + corrige l'une, la correction se perd en silence, et aucun test + d'intégrité existant ne regarde les DOUBLONS — ils lisent le dict + déjà construit, où le doublon a déjà disparu. D'où la lecture par AST + du fichier SOURCE. + """ + + @staticmethod + def _cles_du_source(): + import ast + + chemin = os.path.join( + os.path.dirname(__file__), + "..", + "script", + "todo", + "todo_i18n.py", + ) + with open(chemin, encoding="utf-8") as flux: + arbre = ast.parse(flux.read()) + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Assign): + continue + if getattr(noeud.targets[0], "id", "") != "TRANSLATIONS": + continue + return [ + cle.value + for cle in noeud.value.keys + if isinstance(cle, ast.Constant) and isinstance(cle.value, str) + ] + return [] + + def test_le_dict_source_a_bien_ete_lu(self): + """Si la forme du fichier change, ce test doit tomber ici plutôt + que de déclarer « aucun doublon » sur une liste vide.""" + self.assertGreater(len(self._cles_du_source()), 1000) + + def test_aucune_cle_n_est_declaree_deux_fois(self): + import collections + + compte = collections.Counter(self._cles_du_source()) + doubles = sorted(k for k, n in compte.items() if n > 1) + self.assertEqual(doubles, [], "clés déclarées deux fois") + + +class TestPorteeDeLEmpanDEnTete(unittest.TestCase): + """La portée obéit à l'empan mesuré — l'endroit où une erreur laisse + sortir de la donnée. + + Le défaut de l'ABSENCE porte tout : un appelant qui ne mesure pas + mettrait la ligne de champs en portée, ses libellés remplacés et la + copie illisible. Une clé PRÉSENTE et vide veut dire « pas d'en-tête ». + """ + + BASE = {"entetes": False, "colonnes_intactes": set()} + + def test_la_cle_ABSENTE_garde_la_ligne_1(self): + """Le comportement d'avant la mesure : le gabarit d'options des + tests de portée ne porte que quelques clés.""" + self.assertFalse(noyau.cellule_en_portee("F", 1, 1, dict(self.BASE))) + self.assertTrue(noyau.cellule_en_portee("F", 2, 1, dict(self.BASE))) + + def test_la_cle_PRESENTE_ET_VIDE_met_la_ligne_1_en_portee(self): + """C'est le correctif de la fuite : une feuille sans en-tête voit + sa première ligne de DONNÉES anonymisée.""" + options = dict(self.BASE, lignes_entete=set()) + self.assertTrue(noyau.cellule_en_portee("F", 1, 1, options)) + + def test_un_empan_de_deux_lignes_les_garde_toutes_les_deux(self): + options = dict(self.BASE, lignes_entete={("F", 1), ("F", 2)}) + self.assertFalse(noyau.cellule_en_portee("F", 1, 1, options)) + self.assertFalse(noyau.cellule_en_portee("F", 2, 1, options)) + self.assertTrue(noyau.cellule_en_portee("F", 3, 1, options)) + + def test_l_empan_est_PAR_FEUILLE(self): + """Garder la ligne 3 d'une feuille ne garde pas la ligne 3 des + autres : l'empan est mesuré feuille par feuille.""" + options = dict(self.BASE, lignes_entete={("A", 3)}) + self.assertFalse(noyau.cellule_en_portee("A", 3, 1, options)) + self.assertTrue(noyau.cellule_en_portee("B", 3, 1, options)) + + def test_repondre_oui_a_l_en_tete_met_tout_en_portee(self): + """L'option existante garde sa parole : l'empan ne la contredit + pas.""" + options = dict( + self.BASE, entetes=True, lignes_entete={("F", 1), ("F", 2)} + ) + self.assertTrue(noyau.cellule_en_portee("F", 1, 1, options)) + + def test_preparer_pose_TOUJOURS_la_cle(self): + """Sinon l'absence — qui veut dire « ligne 1 » — s'appliquerait à + une feuille dont l'en-tête est ailleurs.""" + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base, True) + source = os.path.join(base, "s.csv") + with open(source, "w", encoding="utf-8") as flux: + flux.write("etiquette,montant,date\n") + flux.write("aboulie,12,2019-01-02\n") + flux.write("acai,13,2019-01-03\n") + _f, _feuilles, _c, _r, options = formats._preparer(source, {}) + self.assertIn("lignes_entete", options) + # Le nom vient du module : un csv n'a pas d'onglet, et coder son + # nom en dur dans le test ferait tomber au premier renommage. + self.assertEqual( + options["lignes_entete"], + {(formats.NOM_FEUILLE_NEUTRE, 1)}, + ) + + +class TestMesurerEntetes(unittest.TestCase): + """Quatre sources, dans un ordre qui n'est pas négociable.""" + + ENTETE = ["etiquette", "montant", "date", "code"] + CORPS = [ + ["aboulie", 1200, "2019-01-02", "A1"], + ["acai", 830, "2019-01-03", "B2"], + ["adobe", 940, "2019-01-04", "C3"], + ] + + @classmethod + def _feuille(cls, nom="F", lignes=None): + """Types MÊLÉS : le régime où la mesure tranche. Une grille + étroite et tout-alphabétique ne se décide pas.""" + return formats.Feuille( + nom, lignes if lignes is not None else [cls.ENTETE] + cls.CORPS + ) + + def test_la_correction_de_l_operateur_passe_avant_tout(self): + feuille = self._feuille() + feuille.entete_declaree = {1} + formats.mesurer_entetes([feuille], "xlsx", {"F": [2, 3]}) + self.assertEqual(feuille.lignes_entete, {2, 3}) + self.assertEqual(feuille.ligne_champs, 3) + + def test_la_DECLARATION_du_fichier_passe_avant_la_mesure(self): + """`_resynchroniser_tableaux` lit déjà `headerRowCount` pour + nommer les colonnes d'un tableau : deux notions d'en-tête qui se + contredisent feraient renommer depuis une ligne anonymisée.""" + feuille = self._feuille(lignes=[["cat"] * 4, self.ENTETE] + self.CORPS) + feuille.entete_declaree = {1, 2} + formats.mesurer_entetes([feuille], "xlsx") + self.assertEqual(feuille.lignes_entete, {1, 2}) + self.assertEqual(feuille.ligne_champs, 2) + + def test_un_lecteur_qui_FABRIQUE_sa_ligne_1_ne_mesure_rien(self): + for format_lu in formats.FORMATS_ENTETE_FABRIQUEE: + with self.subTest(format_lu=format_lu): + # Une grille SANS en-tête : la mesure rendrait + # `set()`, et le défaut du lecteur doit l'emporter. + feuille = self._feuille(lignes=self.CORPS) + formats.mesurer_entetes([feuille], format_lu) + self.assertEqual(feuille.lignes_entete, {1}) + + def test_une_correction_vide_veut_dire_PAS_d_en_tete(self): + feuille = self._feuille() + formats.mesurer_entetes([feuille], "csv", {"F": []}) + self.assertEqual(feuille.lignes_entete, set()) + self.assertIsNone(feuille.ligne_champs) + + def test_une_correction_ne_touche_pas_les_autres_feuilles(self): + une, deux = self._feuille("A"), self._feuille("B") + formats.mesurer_entetes([une, deux], "csv", {"A": []}) + self.assertEqual(une.lignes_entete, set()) + self.assertEqual(deux.ligne_champs, 1) + + +class TestCorpsSousLEmpan(unittest.TestCase): + """Les lignes de DONNÉES : toutes celles hors de l'empan d'en-tête.""" + + def test_une_donnee_AU_DESSUS_de_la_ligne_de_champs_est_gardee(self): + """Elle est hors de l'empan exprès, et partir de la dernière ligne + d'en-tête la faisait disparaître d'une conversion json ou xml — + anonymisée, comptée comme remplacée, puis absente du fichier.""" + feuille = formats.Feuille( + "F", [["z", 9], ["a", "b"], ["x", 1], ["y", 2]] + ) + feuille.lignes_entete = {2} + self.assertEqual( + formats.corps(feuille), [["z", 9], ["x", 1], ["y", 2]] + ) + + def test_un_empan_NON_contigu_ne_reprend_pas_ce_qu_il_saute(self): + """L'opérateur n'est pas tenu de cocher des lignes voisines.""" + feuille = formats.Feuille( + "F", [["a", "b"], ["x", 1], ["c", "d"], ["y", 2]] + ) + feuille.lignes_entete = {1, 3} + self.assertEqual(formats.corps(feuille), [["x", 1], ["y", 2]]) + + def test_le_corps_saute_tout_l_empan(self): + feuille = formats.Feuille( + "F", [["cat", "cat"], ["a", "b"], ["x", 1], ["y", 2]] + ) + feuille.lignes_entete = {1, 2} + self.assertEqual(formats.corps(feuille), [["x", 1], ["y", 2]]) + + def test_sans_en_tete_le_corps_est_TOUTE_la_grille(self): + """Sinon la première ligne de données devient les noms de clé : + en clair dans la copie, et perdue comme donnée.""" + feuille = formats.Feuille("F", [["x", 1], ["y", 2]]) + feuille.lignes_entete = set() + self.assertEqual(formats.corps(feuille), [["x", 1], ["y", 2]]) + + def test_des_noms_neutres_remplacent_l_en_tete_absent(self): + feuille = formats.Feuille("F", [["x", 1, 2], ["y", 3, 4]]) + self.assertEqual( + formats._noms_neutres(feuille), + ["colonne_1", "colonne_2", "colonne_3"], + ) + + +class TestValeurDExemple(unittest.TestCase): + """Une valeur montrable : toujours une `str`, toujours bornée. + + C'est la seule colonne du rapport qui porte de la donnée du client à + l'écran, et la charge utile traverse un `json.dump` en + `allow_nan=False` : ce qui échoue là échoue APRÈS tout le travail du + moteur, et l'écran n'affiche alors qu'une trace. + """ + + def test_tout_rendu_est_une_chaine(self): + import datetime + + for valeur in ( + None, + 0, + 0.0, + False, + True, + 1200, + "aboulie", + datetime.date(2019, 1, 2), + b"\x00\xff", + ): + with self.subTest(valeur=valeur): + self.assertIsInstance(formats.valeur_d_exemple(valeur), str) + + def test_le_zero_et_le_faux_ne_s_effacent_pas(self): + """`or ""` les écrasait : une colonne de montants nuls montrait + des exemples vides.""" + self.assertEqual(formats.valeur_d_exemple(0), "0") + self.assertEqual(formats.valeur_d_exemple(0.0), "0.0") + self.assertEqual(formats.valeur_d_exemple(False), "False") + + def test_des_octets_ne_sortent_pas_en_clair(self): + """`valeur_hors_tableur` tomberait sur son `str()` final et + rendrait le `repr` d'un `bytes` — du binaire lisible.""" + rendu = formats.valeur_d_exemple(b"\x00\xff\x00") + self.assertNotIn("\\x", rendu) + self.assertIn("3", rendu) + + def test_un_flottant_non_fini_ne_casse_pas_la_serialisation(self): + """`allow_nan=False` lèverait, et le moteur aurait tout fait.""" + for valeur in (float("nan"), float("inf"), -float("inf")): + with self.subTest(valeur=valeur): + self.assertEqual(formats.valeur_d_exemple(valeur), "") + + def test_une_valeur_longue_est_bornee_et_le_dit(self): + rendu = formats.valeur_d_exemple("x" * 5000) + self.assertEqual(len(rendu), formats.EXEMPLE_LONGUEUR) + self.assertTrue(rendu.endswith("…")) + + +class TestRapportDeLEnTete(unittest.TestCase): + """Ce que le rapport DIT de la mesure, pour qu'une invention se lise + avant d'être consentie.""" + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _rapport(self, contenu, nom="s.csv"): + chemin = os.path.join(self.base, nom) + with open(chemin, "w", encoding="utf-8") as flux: + flux.write(contenu) + return formats.report(chemin) + + AVEC = ( + "etiquette,montant,date,code\n" + "aboulie,1200,2019-01-02,A1\n" + "acai,830,2019-01-03,B2\n" + "adobe,940,2019-01-04,C3\n" + ) + SANS = "ZK204817,501,100.5\n" "ZK204818,502,101.5\n" "ZK204819,503,102.5\n" + + def test_l_empan_est_une_LISTE_serialisable(self): + """Un set ne passe pas `json.dump`, et ce rapport traverse un + sous-processus.""" + feuille = self._rapport(self.AVEC)["feuilles"][0] + self.assertEqual(feuille["lignes_entete"], [1]) + self.assertEqual(feuille["ligne_champs"], 1) + json.dumps(feuille, allow_nan=False) + + def test_sans_en_tete_le_rapport_le_dit(self): + feuille = self._rapport(self.SANS)["feuilles"][0] + self.assertEqual(feuille["lignes_entete"], []) + self.assertIsNone(feuille["ligne_champs"]) + self.assertIsNone(feuille["entete_mesure"]) + + def test_les_cinq_mesures_sont_affichables(self): + """L'opérateur voit POURQUOI la mesure a tranché avant de la + contredire.""" + mesure = self._rapport(self.AVEC)["feuilles"][0]["entete_mesure"] + self.assertEqual( + sorted(mesure), + ["accord", "contraste", "distinct", "hors_colonne", "rempli"], + ) + for cle, valeur in mesure.items(): + with self.subTest(cle=cle): + self.assertIsInstance(valeur, float) + self.assertLessEqual(valeur, 1.0) + + def test_chaque_colonne_montre_des_exemples(self): + """CE qui distingue deux colonnes sans libellé : ni le type, ni le + compte, ni les bornes n'y suffisent.""" + colonnes = self._rapport(self.AVEC)["feuilles"][0]["colonnes"] + for colonne in colonnes: + with self.subTest(colonne=colonne["index"]): + self.assertTrue(colonne["exemples"]) + self.assertLessEqual( + len(colonne["exemples"]), formats.EXEMPLES_PAR_COLONNE + ) + + def test_les_exemples_sont_des_valeurs_DISTINCTES(self): + """Trois fois la même ne montre rien de la colonne.""" + rapport = self._rapport( + "etiquette,constante,date\n" + "aboulie,7,2019-01-02\n" + "acai,7,2019-01-03\n" + "adobe,7,2019-01-04\n" + ) + par_index = { + c["index"]: c["exemples"] + for c in rapport["feuilles"][0]["colonnes"] + } + self.assertEqual(par_index[2], ["7"]) + self.assertEqual(len(par_index[1]), 3) + + def test_l_en_tete_de_la_ligne_de_champs_n_est_pas_un_exemple(self): + """Les statistiques sautent l'empan : le libellé n'est pas une + valeur de sa colonne.""" + colonnes = self._rapport(self.AVEC)["feuilles"][0]["colonnes"] + self.assertNotIn("etiquette", colonnes[0]["exemples"]) + + +class TestColonneRepondue(unittest.TestCase): + """La règle des colonnes laissées intactes, en UN endroit. + + Elle vivait en trois copies — la portée, les colonnes écartées, les + colonnes saturées — donc trois occasions de divergence. Et l'ensemble + était GLOBAL : répondre « 3 » gelait la colonne 3 des dix feuilles + d'un classeur, si bien qu'un écran laissant cocher la colonne 3 de la + septième feuille aurait tenu une promesse fausse. + """ + + def test_l_ensemble_plat_reste_global(self): + """La question textuelle répond comme avant : elle ne sait pas de + quelle feuille elle parle.""" + options = {"colonnes_intactes": {"3"}} + self.assertTrue(noyau.colonne_repondue("A", 3, None, options)) + self.assertTrue(noyau.colonne_repondue("B", 3, None, options)) + + def test_l_ensemble_par_feuille_ne_vaut_QUE_pour_elle(self): + options = {"colonnes_intactes_par_feuille": {"A": {"ref"}}} + self.assertTrue(noyau.colonne_repondue("A", 5, "ref", options)) + self.assertFalse(noyau.colonne_repondue("B", 5, "ref", options)) + + def test_l_index_ne_repond_que_sans_etiquette(self): + """Sinon « 1 » désigne à la fois la colonne étiquetée « 1 » et la + première colonne, et une réponse en épargne deux.""" + options = {"colonnes_intactes": {"3"}} + self.assertFalse(noyau.colonne_repondue("A", 3, "nom", options)) + self.assertTrue(noyau.colonne_repondue("A", 3, None, options)) + self.assertTrue(noyau.colonne_repondue("A", 3, " ", options)) + + def test_les_deux_ensembles_se_cumulent(self): + options = { + "colonnes_intactes": {"nom"}, + "colonnes_intactes_par_feuille": {"A": {"ref"}}, + } + self.assertTrue(noyau.colonne_repondue("A", 1, "nom", options)) + self.assertTrue(noyau.colonne_repondue("A", 2, "ref", options)) + self.assertTrue(noyau.colonne_repondue("B", 1, "nom", options)) + self.assertFalse(noyau.colonne_repondue("B", 2, "ref", options)) + + def test_sans_aucune_reponse_rien_n_est_gele(self): + for options in ( + {}, + {"colonnes_intactes": set()}, + {"colonnes_intactes_par_feuille": {}}, + ): + with self.subTest(options=options): + self.assertFalse( + noyau.colonne_repondue("A", 1, "nom", options) + ) + + def test_la_portee_reprend_la_meme_regle(self): + """Le test qui lie les deux : une divergence entre la portée et + cette règle laisserait sortir une colonne annoncée gelée.""" + options = { + "entetes": False, + "lignes_entete": {("A", 1)}, + "colonnes_intactes_par_feuille": {"A": {"ref"}}, + "etiquettes": {("A", 2): "ref"}, + } + self.assertFalse(noyau.cellule_en_portee("A", 2, 2, options)) + self.assertTrue(noyau.cellule_en_portee("B", 2, 2, options)) + + def test_preparer_normalise_le_par_feuille(self): + """Un dict {nom: [str]} traverse le sous-processus ; l'espace + autour d'une réponse tapée à la main ne doit pas la manquer.""" + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base, True) + source = os.path.join(base, "s.csv") + with open(source, "w", encoding="utf-8") as flux: + flux.write("etiquette,montant,date\n") + flux.write("aboulie,12,2019-01-02\n") + flux.write("acai,13,2019-01-03\n") + _f, _fe, _c, _r, options = formats._preparer( + source, + { + "colonnes_intactes_par_feuille": { + formats.NOM_FEUILLE_NEUTRE: [" montant ", ""] + } + }, + ) + self.assertEqual( + options["colonnes_intactes_par_feuille"], + {formats.NOM_FEUILLE_NEUTRE: {"montant"}}, + ) + + +class TestMenuEcranDePerimetre(unittest.TestCase): + """L'ouverture de l'écran, et ses TROIS issues. + + La spec porte le périmètre, `{}` demande les invites, `None` + annule. Confondre les deux dernières supprimerait le repli textuel en + silence. + """ + + RAPPORT = {"feuilles": [{"nom": "F", "colonnes": []}]} + + def setUp(self): + self.menu = _MenuBouchon() + self.ecran = io.StringIO() + vrai_out = sys.stdout + sys.stdout = self.ecran + self.addCleanup(setattr, sys, "stdout", vrai_out) + vrai_input = builtins.input + self.addCleanup(setattr, builtins, "input", vrai_input) + + def _repondre(self, reponse): + builtins.input = lambda invite="": reponse + + def test_refuser_l_ecran_rend_un_dict_VIDE(self): + """Et non None : l'appelant enchaîne sur les invites.""" + self._repondre("n") + self.assertEqual(self.menu._transform_ecran(self.RAPPORT), {}) + + def test_zero_annule_tout(self): + self._repondre("0") + self.assertIsNone(self.menu._transform_ecran(self.RAPPORT)) + + def test_un_rapport_sans_feuille_ne_pose_pas_la_question(self): + def refuse(invite=""): + raise AssertionError("rien à montrer, rien à demander") + + builtins.input = refuse + self.assertEqual(self.menu._transform_ecran({"feuilles": []}), {}) + + def _bouchonner(self, ecran, contexte=None): + """Remplacer la fonction SUR le vrai module. + + Injecter un faux module dans `sys.modules` n'a aucun effet dès + que le vrai a été importé : `from script.todo import + transform_form` lit l'attribut du PAQUET, déjà posé. Le test + lançait alors le VRAI écran, qui attend un terminal — et la suite + se bloquait dès qu'un autre fichier avait importé le module. + """ + from script.todo import transform_form + + for nom, valeur in ( + ("run_transform_form", ecran), + ( + "contexte_depuis_rapport", + contexte or (lambda *args: {}), + ), + ): + vrai = getattr(transform_form, nom) + setattr(transform_form, nom, valeur) + self.addCleanup(setattr, transform_form, nom, vrai) + + def test_le_defaut_est_OUI(self): + """L'écran est la réponse aux deux questions qu'une invite ne sait + pas poser : le proposer par défaut est le sens de l'entrée.""" + appels = [] + self._repondre("") + + def faux_ecran(ctx): + appels.append(ctx) + return {"colonnes_intactes_par_feuille": {}} + + self._bouchonner(faux_ecran, lambda *args: {"vu": True}) + rendu = self.menu._transform_ecran(self.RAPPORT) + self.assertEqual(appels, [{"vu": True}]) + self.assertEqual(rendu, {"colonnes_intactes_par_feuille": {}}) + + def test_un_ecran_qui_leve_ne_perd_pas_le_travail(self): + """Sans terminal, il ne peut pas s'ouvrir : les invites savent + tout demander, et emporter le travail serait pire.""" + self._repondre("o") + + def tombe(ctx): + raise RuntimeError("pas de terminal") + + self._bouchonner(tombe) + self.assertEqual(self.menu._transform_ecran(self.RAPPORT), {}) + self.assertIn("pas de terminal", self.ecran.getvalue()) + + +class TestEmpanDeclareEtendu(unittest.TestCase): + """Un tableau OOXML déclare SA ligne de champs, pas la mise en page. + + `headerRowCount` ignore ce qui est posé au-dessus du tableau. Un titre + de rapport en A1 restait hors de l'empan, les statistiques de colonne + le comptaient comme une valeur, une colonne de relation cessait d'en + avoir la FORME, le plancher lâchait — et les identifiants étaient + permutés dans la copie, toutes ses relations pointant ailleurs, sans + un mot. + """ + + CORPS = [[41 + rang, 100 * (rang + 1)] for rang in range(4)] + CHAMPS = ["partner_id", "montant"] + + def _feuille(self, lignes, declaree): + feuille = formats.Feuille("Ventes", lignes) + feuille.entete_declaree = declaree + formats.mesurer_entetes([feuille], "xlsx") + return feuille + + def test_le_titre_au_dessus_rejoint_l_empan(self): + lignes = [ + ["Rapport mensuel", None], + ["Periode : janvier", None], + self.CHAMPS, + ] + self.CORPS + feuille = self._feuille(lignes, {3}) + self.assertEqual(feuille.lignes_entete, {1, 2, 3}) + # La ligne de CHAMPS ne bouge pas : le tableau la déclare. + self.assertEqual(feuille.ligne_champs, 3) + + def test_les_statistiques_ne_comptent_plus_le_titre(self): + """C'est par là que la corruption passait.""" + lignes = [ + ["Rapport mensuel", None], + ["Periode : janvier", None], + self.CHAMPS, + ] + self.CORPS + colonne = formats._stats_colonnes(self._feuille(lignes, {3}))[0] + self.assertEqual(colonne["etiquette"], "partner_id") + self.assertEqual(colonne["remplies"], 4) + self.assertTrue(colonne["forme_relation"]) + self.assertTrue(colonne["plancher"]) + + def test_un_tableau_en_A1_est_inchange(self): + feuille = self._feuille([self.CHAMPS] + self.CORPS, {1}) + self.assertEqual(feuille.lignes_entete, {1}) + + def test_une_DONNEE_au_dessus_borne_la_remontee_sans_l_annuler(self): + """Le tableau déclare sa ligne de champs : une donnée au-dessus + ne remet pas ce fait en cause, contrairement à la mesure, qui n'a + rien qui le lui dise.""" + lignes = [[45, 500], self.CHAMPS] + self.CORPS + feuille = self._feuille(lignes, {2}) + self.assertEqual(feuille.lignes_entete, {2}) + + def test_un_empan_declare_de_deux_lignes_est_inchange(self): + lignes = [["Bloc", "Bloc"], self.CHAMPS] + self.CORPS + feuille = self._feuille(lignes, {1, 2}) + self.assertEqual(feuille.lignes_entete, {1, 2}) + + +class TestRapportResynchronise(unittest.TestCase): + """Le rapport suit l'empan DÉSIGNÉ, non celui qui a été mesuré. + + `report` mesure avant que l'opérateur réponde, et tout ce qui suit + travaille sur sa réponse. Les deux se lisaient l'un pour l'autre : les + étiquettes, les bornes, les formes et le plancher dérivent du rapport, + la portée de la réponse. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.source = os.path.join(self.base, "s.csv") + with open(self.source, "w", encoding="utf-8") as flux: + flux.write("etiquette,montant,date,code\n") + flux.write("aboulie,1200,2019-01-02,A1\n") + flux.write("acai,830,2019-01-03,B2\n") + flux.write("adobe,940,2019-01-04,C3\n") + flux.write("acanthe,910,2019-01-05,D4\n") + + def _prepare(self, **options): + return formats._preparer( + self.source, + dict( + { + "nombres": True, + "texte": True, + "graine": 7, + "feuilles": [], + "colonnes_intactes": [], + }, + **options, + ), + ) + + def test_la_mesure_elit_la_ligne_1(self): + """Le point de départ, sans quoi le test suivant ne prouve rien.""" + _fmt, _f, _c, rapport, _o = self._prepare() + feuille = rapport["feuilles"][0] + self.assertEqual(feuille["lignes_entete"], [1]) + self.assertEqual( + [c["etiquette"] for c in feuille["colonnes"]], + ["etiquette", "montant", "date", "code"], + ) + + def test_une_correction_refait_les_etiquettes_du_rapport(self): + """Sinon l'aperçu annonce épargnée une colonne que la passe + anonymise : il la reconnaît par l'étiquette de la ligne mesurée, + la portée par celle de la ligne désignée.""" + _fmt, _f, _c, rapport, options = self._prepare( + entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1, 2]} + ) + feuille = rapport["feuilles"][0] + self.assertEqual(feuille["lignes_entete"], [1, 2]) + self.assertEqual(feuille["ligne_champs"], 2) + # La ligne de champs est la 2 : ses valeurs nomment les colonnes. + etiquettes = [c["etiquette"] for c in feuille["colonnes"]] + self.assertEqual(etiquettes, ["aboulie", "1200", "2019-01-02", "A1"]) + # Et le rapport et les options s'accordent, puisque les secondes + # en dérivent. + self.assertEqual( + options["etiquettes"][(formats.NOM_FEUILLE_NEUTRE, 1)], "aboulie" + ) + + def test_les_bornes_suivent_le_corps_designe(self): + """Elles dérivent du rapport : une ligne de plus dans l'en-tête, + c'est une valeur de moins dans les bornes.""" + _fmt, _f, _c, _r, mesure = self._prepare() + _fmt, _f, _c, _r, corrige = self._prepare( + entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1, 2]} + ) + cle = (formats.NOM_FEUILLE_NEUTRE, 2) + self.assertEqual(mesure["bornes"][cle][:2], (830, 1200)) + self.assertEqual(corrige["bornes"][cle][:2], (830, 940)) + + def test_sans_correction_le_rapport_n_est_pas_refait(self): + """Refaire les statistiques coûte un balayage par feuille : une + feuille dont l'empan n'a pas bougé n'y passe pas.""" + appels = [] + vrai = formats._stats_colonnes + formats._stats_colonnes = lambda f: appels.append(f.nom) or vrai(f) + self.addCleanup(setattr, formats, "_stats_colonnes", vrai) + self._prepare() + # Un seul appel : celui de `report`, et pas un de plus. + self.assertEqual(len(appels), 1) + + +class TestReponseParIndex(unittest.TestCase): + """Une case cochée à l'écran survit à une correction d'en-tête. + + Corriger l'empan RENOMME les colonnes — la ligne de champs change, + donc les étiquettes aussi. Une réponse portée par l'étiquette tombait + alors sur une autre colonne, ou sur aucune, sans que rien ne le dise : + la colonne était anonymisée malgré la case. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.source = os.path.join(self.base, "s.csv") + with open(self.source, "w", encoding="utf-8") as flux: + flux.write("etiquette,montant,date,code\n") + flux.write("aboulie,1200,2019-01-02,A1\n") + flux.write("acai,830,2019-01-03,B2\n") + flux.write("adobe,940,2019-01-04,C3\n") + flux.write("acanthe,910,2019-01-05,D4\n") + + def _plan(self, **options): + return formats.plan( + self.source, + dict( + { + "nombres": True, + "texte": True, + "graine": 7, + "feuilles": [], + "destination": os.path.join(self.base, "o.csv"), + }, + **options, + ), + ) + + EMPAN_CORRIGE = {formats.NOM_FEUILLE_NEUTRE: [1, 2]} + + def test_l_index_porte_a_travers_la_correction(self): + apercu = self._plan( + entetes_par_feuille=self.EMPAN_CORRIGE, + colonnes_intactes_index_par_feuille={ + formats.NOM_FEUILLE_NEUTRE: [2] + }, + ) + self.assertEqual( + [c["index"] for c in apercu["colonnes_ecartees"]], [2] + ) + self.assertFalse( + [c for c in apercu["apercu"] if c["cellule"].endswith("C2")] + ) + + def test_l_etiquette_MONTREE_ne_porte_plus_apres_la_correction(self): + """Le comportement que l'index remplace, gardé comme repère : la + réponse ne tombe nulle part, et la colonne part. + """ + apercu = self._plan( + entetes_par_feuille=self.EMPAN_CORRIGE, + colonnes_intactes_par_feuille={ + formats.NOM_FEUILLE_NEUTRE: ["montant"] + }, + ) + self.assertEqual(apercu["colonnes_ecartees"], []) + self.assertTrue( + [c for c in apercu["apercu"] if c["cellule"].endswith("C2")] + ) + + def test_sans_correction_l_index_porte_aussi(self): + apercu = self._plan( + colonnes_intactes_index_par_feuille={ + formats.NOM_FEUILLE_NEUTRE: [2] + } + ) + self.assertEqual( + [c["etiquette"] for c in apercu["colonnes_ecartees"]], ["montant"] + ) + + def test_un_index_illisible_est_saute_sans_emporter_le_travail(self): + """La spec traverse un JSON : une clé illisible arrive.""" + apercu = self._plan( + colonnes_intactes_index_par_feuille={ + formats.NOM_FEUILLE_NEUTRE: [2, "x", None, 0, -3] + } + ) + self.assertEqual( + [c["index"] for c in apercu["colonnes_ecartees"]], [2] + ) + + def test_l_index_d_une_AUTRE_feuille_ne_porte_pas(self): + apercu = self._plan( + colonnes_intactes_index_par_feuille={"Une autre": [2]} + ) + self.assertEqual(apercu["colonnes_ecartees"], []) + + +class TestColonnesEnClair(unittest.TestCase): + """Une colonne qui sort ENTIÈRE en clair est nommée. + + Le critère est unique et ne dépend d'aucune raison : elle porte + quelque chose, et rien n'y a été remplacé. Compter par famille sur + tout le fichier disait POURQUOI sans dire OÙ, et c'est la colonne qui + se transmet, non la famille. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _plan(self, contenu, **options): + source = os.path.join(self.base, "s.json") + with open(source, "w", encoding="utf-8") as flux: + json.dump(contenu, flux) + return formats.plan( + source, + dict( + { + "nombres": True, + "texte": True, + "graine": 7, + "feuilles": [], + "colonnes_intactes": [], + "destination": os.path.join(self.base, "o.json"), + }, + **options, + ), + ) + + ENREGISTREMENTS = [ + {"etiquette": mot, "actif": True, "montant": 1200 + rang} + for rang, mot in enumerate(("aboulie", "acai", "adobe")) + ] + + def test_une_colonne_de_booleens_est_nommee(self): + """Les booléens traversent par RÈGLE : rien ne les remplace.""" + apercu = self._plan(self.ENREGISTREMENTS) + self.assertEqual( + [ + (c["etiquette"], c["cellules"]) + for c in apercu["colonnes_en_clair"] + ], + [("actif", 3)], + ) + + def test_une_colonne_remplacee_n_y_figure_pas(self): + apercu = self._plan(self.ENREGISTREMENTS) + nommees = {c["etiquette"] for c in apercu["colonnes_en_clair"]} + self.assertNotIn("etiquette", nommees) + self.assertNotIn("montant", nommees) + + def test_une_colonne_qu_une_REPONSE_explique_n_y_revient_pas(self): + """Elle est déjà nommée par les colonnes écartées : la redire + apprend à ne plus lire la ligne.""" + apercu = self._plan( + self.ENREGISTREMENTS, colonnes_intactes={"montant"} + ) + ecartees = {c["etiquette"] for c in apercu["colonnes_ecartees"]} + nommees = {c["etiquette"] for c in apercu["colonnes_en_clair"]} + self.assertIn("montant", ecartees) + self.assertNotIn("montant", nommees) + + +class TestApercuDesLignesSondees(unittest.TestCase): + """L'écran existe pour faire juger QUELLE ligne nomme les colonnes. + + La tranche des trois premières cellules, posée AVANT le filtre des + vides, ne montrait rien d'une ligne dont les trois premières sont + vides : un bloc d'en-tête décalé de quelques colonnes donnait des + lignes d'aperçu toutes vides, et l'opérateur ne pouvait que parier. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.source = os.path.join(self.base, "decale.csv") + with open(self.source, "w", encoding="utf-8") as flux: + flux.write(",,,Rapport interne\n") + flux.write(",,,\n") + flux.write(",,,nom,ville,total\n") + flux.write(",,,Aubel,Nord,10\n") + flux.write(",,,Bruant,Sud,20\n") + flux.write(",,,Cerdan,Est,30\n") + + def _sondees(self): + rendu = formats.report(self.source)["feuilles"][0]["lignes_sondees"] + return {ligne["numero"]: ligne for ligne in rendu} + + def test_un_bloc_DECALE_montre_quand_meme_ses_valeurs(self): + sondees = self._sondees() + self.assertEqual(sondees[3]["apercu"], ["nom", "ville", "total"]) + self.assertEqual(sondees[1]["apercu"], ["Rapport interne"]) + + def test_une_ligne_vraiment_vide_ne_montre_rien(self): + self.assertEqual(self._sondees()[2]["apercu"], []) + + def test_l_apercu_reste_borne_a_trois_valeurs(self): + """Filtrer avant de borner ne doit pas dérouler la ligne.""" + for ligne in self._sondees().values(): + self.assertLessEqual( + len(ligne["apercu"]), formats.EXEMPLES_PAR_COLONNE + ) + + def test_le_compte_des_pleines_separe_le_titre_des_champs(self): + """Il est montré à l'écran : c'est le seul signal quand les + mesures se taisent, une feuille sans en-tête retenu n'en ayant + pas.""" + sondees = self._sondees() + self.assertEqual(sondees[1]["pleines"], 1) + self.assertEqual(sondees[2]["pleines"], 0) + self.assertEqual(sondees[3]["pleines"], 3) + + +class TestExemplesManquants(unittest.TestCase): + """Au-delà du plafond, les colonnes n'ont pas d'exemple, et le + rapport le COMPTE. + + Une colonne sans exemple se lit comme une colonne vide : l'opérateur + la laisse intacte, ou non, sans l'avoir vue. Un booléen que personne + ne lisait n'en disait pas plus que la case vide elle-même. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _large(self, colonnes): + chemin = os.path.join(self.base, "large.csv") + with open(chemin, "w", encoding="utf-8") as flux: + flux.write(",".join("C%04d" % i for i in range(colonnes)) + "\n") + for rang in range(3): + flux.write( + ",".join(str(rang * colonnes + i) for i in range(colonnes)) + + "\n" + ) + return chemin + + def test_le_depassement_est_compte(self): + large = formats.EXEMPLES_COLONNES_MAX + 5 + feuille = formats.report(self._large(large))["feuilles"][0] + self.assertEqual(feuille["exemples_manquants"], 5) + self.assertEqual(feuille["colonnes"][-1]["exemples"], []) + + def test_en_deca_du_plafond_le_compte_est_nul(self): + feuille = formats.report(self._large(4))["feuilles"][0] + self.assertEqual(feuille["exemples_manquants"], 0) + self.assertTrue(feuille["colonnes"][-1]["exemples"]) + + +class TestPorteeDeLOperateur(unittest.TestCase): + """L'écran montre plus loin que la mesure ne cherche. + + La borne de la mesure dit « au-delà, je ne devine plus » ; la prendre + pour la borne de l'écran rendait INATTEIGNABLE la ligne de champs d'un + rapport mis en page, alors que le commentaire de la borne promettait + justement que l'opérateur corrige. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.source = os.path.join(self.base, "rapport.csv") + with open(self.source, "w", encoding="utf-8") as flux: + for rang in range(1, formats.LIGNES_SONDEES + 2): + flux.write("Titre %d,,\n" % rang) + # La ligne de champs, hors de portée de la mesure. Sa + # première cellule est un NOMBRE écrit en toutes lettres : + # c'est ce qui révèle si ses brutes ont été gardées. + self.champs = formats.LIGNES_SONDEES + 2 + flux.write("2024,Champ,Montant\n") + for rang in range(6): + flux.write("%d,Aubel,%d\n" % (2000 + rang, 100 + rang)) + + def _plan(self, **options): + return formats.plan( + self.source, + dict( + { + "nombres": True, + "texte": True, + "graine": 7, + "feuilles": [], + "colonnes_intactes": [], + "destination": os.path.join(self.base, "o.csv"), + }, + **options, + ), + ) + + def test_l_ecran_montre_la_ligne_que_la_mesure_n_atteint_pas(self): + sondees = formats.report(self.source)["feuilles"][0]["lignes_sondees"] + numeros = [ligne["numero"] for ligne in sondees] + self.assertIn(self.champs, numeros) + self.assertLessEqual(len(numeros), formats.LIGNES_MONTREES) + + def test_au_dela_de_la_mesure_la_colonne_des_mesures_est_VIDE(self): + """Elle dit la vérité : la mesure n'est pas allée jusque-là. + + La calculer coûte un balayage de la feuille PAR ligne, et trente + balayages d'un classeur de deux millions de cellules se voient. + """ + sondees = formats.report(self.source)["feuilles"][0]["lignes_sondees"] + par_rang = {ligne["numero"]: ligne for ligne in sondees} + self.assertIsNotNone(par_rang[formats.LIGNES_SONDEES]["mesure"]) + self.assertIsNone(par_rang[self.champs]["mesure"]) + + def test_une_ligne_designee_au_dela_garde_ses_valeurs_BRUTES(self): + """La fenêtre des brutes suit la portée de l'écran. + + Bornée à celle de la mesure, une ligne d'en-tête désignée plus bas + retrouvait ses valeurs coercées : « 2024 » redevenait le nombre + 2024, et l'en-tête de la copie portait un nombre là où le fichier + porte un libellé. + """ + apercu = self._plan( + entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [self.champs]} + ) + valeurs = [c["valeur"] for c in apercu["entete_gardee"]] + self.assertIn("2024", valeurs) + self.assertNotIn(2024, valeurs) + + +class TestEntreeInstallation(unittest.TestCase): + """« Installer l'environnement de lecture » dit ce qui est là. + + Elle interrogeait le gestionnaire de paquets pour savoir si l'outil + existait, et rendait son ignorance comme une absence : « aucun paquet + connu » devant un `mdb-queries` installé. + """ + + def setUp(self): + self.menu = _MenuBouchon() + self.sortie = io.StringIO() + vrai = sys.stdout + sys.stdout = self.sortie + self.addCleanup(setattr, sys, "stdout", vrai) + # Le venv de lecture est supposé prêt : la branche qui le BÂTIT + # n'a rien à faire dans une suite unitaire. + self._remplacer("available", lambda fmt=None: True) + + def _remplacer(self, nom, valeur): + vrai = getattr(transform_setup, nom) + setattr(transform_setup, nom, valeur) + self.addCleanup(setattr, transform_setup, nom, vrai) + + def test_l_outil_present_est_ANNONCE(self): + self._remplacer("requetes_access_lisibles", lambda: True) + self._remplacer( + "system_packages_cmd", lambda: ["ne-doit-pas-etre-appele"] + ) + self.menu._transform_install_env() + rendu = self.sortie.getvalue() + self.assertIn( + todo_i18n.t("Access saved queries are readable here."), rendu + ) + self.assertNotIn("ne-doit-pas-etre-appele", rendu) + + def test_sans_outil_ni_paquet_connu_l_entree_dit_QUOI_FAIRE(self): + """Le constat seul laissait l'opérateur sans suite.""" + self._remplacer("requetes_access_lisibles", lambda: False) + self._remplacer("system_packages_cmd", lambda: None) + self.menu._transform_install_env() + rendu = self.sortie.getvalue() + self.assertIn("mdb-queries", rendu) + self.assertIn( + todo_i18n.t("Install it by hand to read Access saved queries."), + rendu, + ) + + +class TestCapaciteDesRequetes(unittest.TestCase): + """La présence de l'outil 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é. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.ancien = os.environ.get("PATH", "") + self.addCleanup(os.environ.__setitem__, "PATH", self.ancien) + + def _poser(self, present): + dossier = os.path.join(self.base, "bin") + os.makedirs(dossier, exist_ok=True) + if present: + chemin = os.path.join(dossier, "mdb-queries") + with open(chemin, "w", encoding="utf-8") as flux: + flux.write("#!/bin/sh\ntrue\n") + os.chmod(chemin, 0o755) + os.environ["PATH"] = dossier + + def test_present_sur_le_PATH(self): + self._poser(True) + self.assertTrue(transform_setup.requetes_access_lisibles()) + self.assertTrue(transform_setup.capabilities()["access queries"]) + + def test_absent_du_PATH(self): + self._poser(False) + self.assertFalse(transform_setup.requetes_access_lisibles()) + self.assertFalse(transform_setup.capabilities()["access queries"]) + + def test_access_reste_lisible_sans_l_outil(self): + """Les deux questions sont distinctes : une base Access se lit + sans mdbtools, seulement ses requêtes non.""" + self._poser(False) + capacites = transform_setup.capabilities() + self.assertIn("access", capacites) + self.assertFalse(capacites["access queries"]) + + +class TestRequetesAccess(unittest.TestCase): + """Les requêtes enregistrées : comptées quand on sait, dites sinon. + + `access-parser` n'en rend aucune — il lit les TABLES, et le catalogue + où elles vivent est écarté exprès. `mdb-queries` de mdbtools les + liste, et son absence ne se devine pas : `None` veut dire « personne + ici ne sait le dire », `[]` veut dire « cette base n'en porte aucune », + et les confondre annonçait une absence là où il y avait ignorance. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.chemin = os.path.join(self.base, "b.mdb") + with open(self.chemin, "wb") as flux: + flux.write(b"\x00" * 16) + + def _bouchon(self, corps, code=0): + """Un `mdb-queries` de substitution, posé en tête de PATH. + + Aucune base Access de ce dépôt ne porte de requête, et mdbtools + ne sait pas en écrire : le bouchon est la seule façon d'éprouver + les trois branches, et il éprouve bien ce qui est à éprouver — la + lecture de la sortie, non le format JET. + """ + dossier = os.path.join(self.base, "bin") + os.makedirs(dossier, exist_ok=True) + chemin = os.path.join(dossier, "mdb-queries") + with open(chemin, "w", encoding="utf-8") as flux: + flux.write("#!/bin/sh\n%s\nexit %d\n" % (corps, code)) + os.chmod(chemin, 0o755) + ancien = os.environ.get("PATH", "") + os.environ["PATH"] = dossier + os.pathsep + ancien + self.addCleanup(os.environ.__setitem__, "PATH", ancien) + + def test_sans_le_binaire_on_ne_SAIT_pas(self): + ancien = os.environ.get("PATH", "") + os.environ["PATH"] = os.path.join(self.base, "vide") + self.addCleanup(os.environ.__setitem__, "PATH", ancien) + self.assertIsNone(formats.requetes_access(self.chemin)) + + def test_une_base_sans_requete_rend_une_liste_VIDE(self): + """Et non None : l'aperçu n'a alors rien à annoncer.""" + self._bouchon("true") + self.assertEqual(formats.requetes_access(self.chemin), []) + + def test_les_noms_sont_rendus_un_par_ligne(self): + self._bouchon("printf 'Factures\\nSoldes\\n\\n Totaux \\n'") + self.assertEqual( + formats.requetes_access(self.chemin), + ["Factures", "Soldes", "Totaux"], + ) + + def test_un_binaire_qui_echoue_vaut_une_IGNORANCE(self): + """Non une absence : une base illisible par mdbtools n'est pas une + base sans requête.""" + self._bouchon("echo 'unable to open'", code=1) + self.assertIsNone(formats.requetes_access(self.chemin)) + + +class TestRapportDesRequetes(unittest.TestCase): + """Ce que l'aperçu dit des requêtes, dans les trois cas.""" + + def setUp(self): + self.menu = _MenuBouchon() + self.sortie = io.StringIO() + vrai = sys.stdout + sys.stdout = self.sortie + self.addCleanup(setattr, sys, "stdout", vrai) + + RAPPORT = {"chemin": "b.mdb", "taille": 1, "format": "access"} + + def test_les_requetes_sont_NOMMEES(self): + """Un chiffre ne dit pas ce qui manque à la copie.""" + rendu = self.menu._transform_render_report( + dict(self.RAPPORT, requetes=["Factures", "Soldes"]) + ) + self.assertIn(todo_i18n.t("saved query(ies)"), rendu) + self.assertIn("Factures", rendu) + self.assertIn("Soldes", rendu) + + def test_au_dela_de_douze_le_reste_est_COMPTE(self): + rendu = self.menu._transform_render_report( + dict(self.RAPPORT, requetes=["R%02d" % n for n in range(20)]) + ) + self.assertIn("R00", rendu) + self.assertNotIn("R19", rendu) + self.assertIn(todo_i18n.t("more, not listed"), rendu) + + def test_sans_requete_rien_n_est_dit(self): + rendu = self.menu._transform_render_report(dict(self.RAPPORT)) + self.assertNotIn(todo_i18n.t("saved query(ies)"), rendu) + + +class TestTableAbimee(unittest.TestCase): + """Une table abîmée refuse en la NOMMANT. + + L'exception nue remontait au filet de dernier recours, qui la rendait + sous « format non reconnu » — donc en accusant le classeur source, que + l'opérateur concluait corrompu. Le cas arrive de deux façons : une + table tronquée par une interruption, et un autre fichier de + `private/` désigné à l'invite. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _table(self, contenu): + chemin = os.path.join(self.base, "t.json") + with open(chemin, "w", encoding="utf-8") as flux: + flux.write(contenu) + return chemin + + ABIMEES = { + "tronquée": '{"version": 2, "mots": {"Aubel": "pomme"', + "racine liste": "[1, 2, 3]", + "racine chaîne": '"une table"', + "vide": "", + } + + def test_le_refus_NOMME_la_table(self): + for genre, contenu in self.ABIMEES.items(): + with self.subTest(genre=genre): + chemin = self._table(contenu) + with self.assertRaises(formats.ErreurMoteur) as capture: + noyau.Correspondance.charger(chemin) + self.assertEqual(capture.exception.cle, "table_illisible") + self.assertIn(chemin, capture.exception.detail) + self.assertTrue(capture.exception.conseil) + + def test_la_cle_a_son_libelle_et_sa_traduction(self): + """Sans quoi le message sortirait en clé brute.""" + libelle = noyau.ERREURS["table_illisible"] + self.assertIn(libelle, todo_i18n.TRANSLATIONS) + + def test_un_chemin_absent_rend_une_table_NEUVE(self): + """Le cas normal du premier fichier d'un lot.""" + table = noyau.Correspondance.charger( + os.path.join(self.base, "rien.json") + ) + self.assertEqual(table.mots, {}) + + def test_le_menu_ouvre_l_ecran_malgre_une_table_abimee(self): + """La mémoire est un confort : son absence n'empêche rien.""" + for genre, contenu in self.ABIMEES.items(): + with self.subTest(genre=genre): + self.assertEqual( + _tm().TransformMenuMixin._transform_memoire( + self._table(contenu) + ), + {}, + ) + + +class TestMemoireJusquALEcran(unittest.TestCase): + """La table du disque, lue par le menu, pré-cochée par l'écran. + + Les tests de chaque bout passaient sur un bouchon : celui du menu + rendait une mémoire posée à la main, celui de l'écran ne montait pas + le menu. Le chemin entier n'était éprouvé par personne, alors que + c'est lui qui porte la promesse — un lot entamé se reprend sans + redire. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.table = os.path.join(self.base, "lot.json") + + RAPPORT = { + "fichier": "f.csv", + "format": "csv", + "feuilles": [ + { + "nom": "Ventes", + "colonnes": [ + { + "index": 1, + "etiquette": "a", + "type": "texte", + "remplies": 3, + "distinctes": 3, + "exemples": ["x"], + "plancher": False, + } + ], + # La MESURE dit « ligne 1 » ; la table, « 1 et 2 ». + "lignes_entete": [1], + "lignes_sondees": [ + { + "numero": n, + "apercu": ["v"], + "pleines": 1, + "mesure": None, + } + for n in (1, 2) + ], + } + ], + } + + def _contexte(self): + from script.todo import transform_form + + memoire = _tm().TransformMenuMixin._transform_memoire(self.table) + return transform_form.contexte_depuis_rapport(self.RAPPORT, memoire) + + def test_la_table_ecrite_arrive_pre_cochee_a_l_ecran(self): + noyau.Correspondance(entetes={"Ventes": [1, 2]}).ecrire(self.table) + feuille = self._contexte()["feuilles"][0] + self.assertEqual(feuille["lignes_entete"], [1, 2]) + self.assertTrue(feuille["entete_memorisee"]) + + def test_sans_table_la_mesure_reste(self): + feuille = self._contexte()["feuilles"][0] + self.assertEqual(feuille["lignes_entete"], [1]) + self.assertFalse(feuille["entete_memorisee"]) + + +class TestMemoireDesEntetes(unittest.TestCase): + """La table de lot se rappelle les lignes d'en-tête corrigées. + + Le deuxième fichier d'un même export porte les mêmes feuilles : la + correction n'est à faire qu'une fois. Ce qui est retenu est la + RÉPONSE de l'opérateur et rien d'autre — retenir une mesure ferait + propager son erreur à tout le lot, alors qu'elle se refait à + l'identique sur chaque fichier. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.table = os.path.join(self.base, "t.json") + + def _csv(self, nom): + chemin = os.path.join(self.base, nom) + with open(chemin, "w", encoding="utf-8") as flux: + flux.write("ZK204817,501,100.5\n") + flux.write("ZK204818,502,101.5\n") + flux.write("ZK204819,503,102.5\n") + return chemin + + OPTIONS = {"nombres": True, "texte": True, "graine": 7, "feuilles": []} + + def _ecrire(self, nom, **options): + formats.ecrire( + self._csv(nom), + os.path.join(self.base, nom + ".out"), + dict(self.OPTIONS, table_chemin=self.table, **options), + ) + return noyau.Correspondance.charger(self.table).entetes + + def test_la_table_se_rappelle_ce_que_l_operateur_a_CORRIGE(self): + retenu = self._ecrire( + "un.csv", + entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1]}, + entetes_corrigees=[formats.NOM_FEUILLE_NEUTRE], + ) + self.assertEqual(retenu, {formats.NOM_FEUILLE_NEUTRE: [1]}) + + def test_un_empan_MESURÉ_n_est_pas_retenu(self): + """L'écran rend l'empan de CHAQUE feuille — c'est ce qui a été + montré, et c'est là-dessus que l'opérateur a consenti. Le retenir + imposerait la mesure d'un fichier à tout le lot, jusque là où la + mesure du suivant dirait autre chose : une erreur de mesure + devenait la loi du lot. + """ + retenu = self._ecrire( + "deux.csv", + entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1]}, + entetes_corrigees=[], + ) + self.assertEqual(retenu, {}) + + def test_sans_la_liste_des_corrections_rien_n_est_retenu(self): + """Un appelant qui ne la fournit pas — le dialogue textuel — ne + doit rien inscrire : il ne sait pas ce qui a été corrigé.""" + retenu = self._ecrire( + "trois.csv", + entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1]}, + ) + self.assertEqual(retenu, {}) + + def test_une_correction_qui_ne_nomme_aucune_feuille_connue_est_ignoree( + self, + ): + retenu = self._ecrire( + "quatre.csv", + entetes_par_feuille={formats.NOM_FEUILLE_NEUTRE: [1]}, + entetes_corrigees=["Une autre feuille"], + ) + self.assertEqual(retenu, {}) + + def test_le_fichier_suivant_du_lot_herite_de_la_correction(self): + """Sans qu'on redise rien : c'est tout l'objet de la mémoire.""" + table = noyau.Correspondance(entetes={formats.NOM_FEUILLE_NEUTRE: [1]}) + table.ecrire(self.table) + apercu = formats.plan( + self._csv("deux.csv"), + dict( + self.OPTIONS, + table_chemin=self.table, + destination=os.path.join(self.base, "b.csv"), + ), + ) + valeurs = [c["valeur"] for c in apercu["entete_gardee"]] + self.assertIn("ZK204817", valeurs) + + def test_sans_memoire_la_mesure_tranche_et_la_ligne_1_est_en_portee(self): + apercu = formats.plan( + self._csv("trois.csv"), + dict( + self.OPTIONS, + destination=os.path.join(self.base, "c.csv"), + ), + ) + self.assertEqual(apercu["entete_gardee"], []) + + def test_la_reponse_de_CE_passage_passe_avant_la_memoire(self): + """L'opérateur peut toujours contredire ce que le lot a établi.""" + feuille = formats.Feuille( + "F", [["a", "b"], ["x", 1], ["y", 2], ["z", 3]] + ) + formats.mesurer_entetes([feuille], "csv", {"F": [2]}, {"F": [1]}) + self.assertEqual(feuille.lignes_entete, {2}) + + def test_la_memoire_passe_avant_ce_que_le_FICHIER_declare(self): + """C'est une réponse d'opérateur elle aussi, faite sur un autre + fichier du même lot.""" + feuille = formats.Feuille( + "F", [["a", "b"], ["x", 1], ["y", 2], ["z", 3]] + ) + feuille.entete_declaree = {1} + formats.mesurer_entetes([feuille], "xlsx", None, {"F": [1, 2]}) + self.assertEqual(feuille.lignes_entete, {1, 2}) + + def test_une_memoire_qui_ne_nomme_pas_la_feuille_ne_fait_rien(self): + feuille = formats.Feuille( + "F", [["a", "b"], ["x", 1], ["y", 2], ["z", 3]] + ) + feuille.entete_declaree = {1} + formats.mesurer_entetes([feuille], "xlsx", None, {"Autre": [9]}) + self.assertEqual(feuille.lignes_entete, {1}) + + +class TestTableVersion2(unittest.TestCase): + """La table porte un troisième dictionnaire, sans casser l'ancienne.""" + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.chemin = os.path.join(self.base, "t.json") + + def test_une_table_de_version_1_se_charge_toujours(self): + """Sinon le deuxième fichier d'un lot commencé avant refuserait + la table du premier.""" + with open(self.chemin, "w", encoding="utf-8") as flux: + json.dump( + {"version": 1, "mots": {"a": "aboulie"}, "nombres": {}}, flux + ) + relue = noyau.Correspondance.charger(self.chemin) + self.assertEqual(relue.mots, {"a": "aboulie"}) + self.assertEqual(relue.entetes, {}) + + def test_l_ecriture_reste_en_0600(self): + """Ce fichier porte chaque valeur d'origine en clair.""" + noyau.Correspondance(entetes={"F": [1]}).ecrire(self.chemin) + self.assertEqual(os.stat(self.chemin).st_mode & 0o777, 0o600) + + def test_les_lignes_sont_normalisees_et_triees(self): + """Une réponse tapée à la main, ou relue d'un JSON, arrive en + chaînes et dans n'importe quel ordre.""" + table = noyau.Correspondance(entetes={"F": ["3", 1, "1", 0, -2]}) + self.assertEqual(table.entetes, {"F": [1, 3]}) + + def test_en_dict_porte_les_trois_dictionnaires(self): + rendu = noyau.Correspondance( + mots={"a": "b"}, nombres={"i:1": 2}, entetes={"F": [1]} + ).en_dict() + self.assertEqual(sorted(rendu), ["entetes", "mots", "nombres"]) + json.dumps(rendu, allow_nan=False) + + +class TestCalibreDesChiffres(unittest.TestCase): + """« Garder le nombre de chiffres » : 8839 tire dans 1000..9999. + + L'option échange l'étendue mesurée de la colonne contre celle des + nombres de MÊME LARGEUR. Les deux ne tiennent pas ensemble — une + colonne mêle des largeurs — et c'est l'opérateur qui tranche. + """ + + def setUp(self): + self.rng = random.Random(1234) + self.table = noyau.Correspondance() + + def _tirer(self, valeur, bornes=(1, 999999, True)): + return noyau.nouveau_nombre( + valeur, self.rng, bornes=bornes, table=self.table, calibre=True + ) + + def test_la_largeur_est_conservee(self): + for valeur in (7, 42, 839, 8839, 12345, 987654): + with self.subTest(valeur=valeur): + rendu = self._tirer(valeur) + self.assertEqual( + len(str(abs(int(rendu)))), len(str(abs(valeur))) + ) + + def test_le_signe_l_est_aussi(self): + for valeur in (-7, -8839): + with self.subTest(valeur=valeur): + rendu = self._tirer(valeur) + self.assertLess(rendu, 0) + self.assertEqual( + len(str(abs(int(rendu)))), len(str(abs(valeur))) + ) + + def test_la_valeur_ne_se_rend_pas_a_elle_meme(self): + for _ in range(30): + self.assertNotEqual(self._tirer(8839), 8839) + + def test_sous_l_unite_l_etendue_MESUREE_l_emporte(self): + """« Garder le nombre de chiffres » n'a pas de sens pour 0,15 : + appliquer la règle quand même tirerait un taux entre 1 et 9, ce + que l'étendue mesurée existe pour empêcher.""" + self.assertIsNone(noyau.bornes_du_meme_calibre(0.15)) + for _ in range(30): + rendu = self._tirer(0.15, bornes=(0.1, 0.9, False)) + self.assertLess(rendu, 1) + self.assertGreater(rendu, 0) + + def test_les_bornes_du_calibre(self): + self.assertEqual(noyau.bornes_du_meme_calibre(8839), (1000, 9999)) + self.assertEqual(noyau.bornes_du_meme_calibre(7), (1, 9)) + self.assertEqual(noyau.bornes_du_meme_calibre(-42), (-99, -10)) + # La partie ENTIÈRE décide : un montant garde sa largeur. + self.assertEqual(noyau.bornes_du_meme_calibre(1234.56), (1000, 9999)) + + def test_sans_l_option_l_etendue_mesuree_gouverne(self): + """La règle par défaut ne bouge pas : c'est elle qui empêche un + taux de devenir 743.""" + rng = random.Random(1234) + for _ in range(30): + rendu = noyau.nouveau_nombre( + 8839, rng, bornes=(1, 20, True), table=noyau.Correspondance() + ) + self.assertLessEqual(rendu, 20) + + def test_l_etendue_ABANDONNEE_est_annoncee(self): + """L'option échange une garantie contre une autre : le dire est + la moitié du travail. Sans l'avertissement, un taux qui sort de + sa plage se découvre à la réimportation.""" + import tempfile + + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base, True) + source = os.path.join(base, "m.csv") + with open(source, "w", encoding="utf-8") as flux: + flux.write("ref,montant\nA,8839\nB,9124\nC,7733\n") + marqueur = "digit count" + for calibre in (False, True): + with self.subTest(calibre=calibre): + apercu = formats.plan( + source, + { + "nombres": True, + "texte": True, + "graine": 7, + "feuilles": [], + "colonnes_intactes": [], + "calibre_chiffres": calibre, + "destination": os.path.join(base, "o.csv"), + }, + ) + dits = [a for a in apercu["avertissements"] if marqueur in a] + self.assertEqual(bool(dits), calibre) + + def test_l_integrite_de_la_table_tient_toujours(self): + """Même clé, même nombre — c'est ce qui garde les jointures.""" + premier = self._tirer(8839) + self.assertEqual(self._tirer(8839), premier) + + def test_deux_valeurs_distinctes_ne_collisionnent_pas(self): + rendus = {self._tirer(v) for v in range(1000, 1040)} + self.assertEqual(len(rendus), 40) + + +class TestNombre(unittest.TestCase): + """Le signe, le zéro, le type, et l'étendue mesurée.""" + + def setUp(self): + self.rng = random.Random(1234) + + def test_positif_reste_positif(self): + for _ in range(50): + self.assertGreater(noyau.nouveau_nombre(7, self.rng), 0) + + def test_negatif_reste_negatif(self): + for _ in range(50): + self.assertLess(noyau.nouveau_nombre(-7, self.rng), 0) + + def test_zero_reste_zero(self): + # Zéro n'a pas de signe à préserver, et un zéro qui devient 743 + # fabrique de la donnée là où il n'y en avait pas. + self.assertEqual(noyau.nouveau_nombre(0, self.rng), 0) + self.assertEqual(noyau.nouveau_nombre(0.0, self.rng), 0.0) + + def test_entier_rend_entier(self): + valeur = noyau.nouveau_nombre(5, self.rng) + self.assertIsInstance(valeur, int) + self.assertNotIsInstance(valeur, bool) + + def test_flottant_rend_flottant(self): + self.assertIsInstance(noyau.nouveau_nombre(5.5, self.rng), float) + + def test_repli_zero_mille_sans_bornes(self): + for _ in range(200): + self.assertLessEqual(abs(noyau.nouveau_nombre(5, self.rng)), 1000) + + def test_tirage_dans_les_bornes(self): + for _ in range(200): + valeur = noyau.nouveau_nombre(2024, self.rng, bornes=(2000, 2030)) + self.assertGreaterEqual(valeur, 2000) + self.assertLessEqual(valeur, 2030) + + def test_colonne_zero_un_ne_rend_jamais_743(self): + """La leçon d'anonymize.py : un taux à 743 fait lever l'ORM.""" + for _ in range(200): + valeur = noyau.nouveau_nombre(0.15, self.rng, bornes=(0.0, 1.0)) + self.assertGreaterEqual(valeur, 0.0) + self.assertLessEqual(valeur, 1.0) + + def test_bornes_negatives_gardent_le_signe_et_l_etendue(self): + for _ in range(200): + valeur = noyau.nouveau_nombre(-3, self.rng, bornes=(-50, 200)) + self.assertLess(valeur, 0) + self.assertGreaterEqual(valeur, -50) + + def test_meme_valeur_meme_sortie(self): + """Sans table, une clé de jointure se désagrège ligne à ligne.""" + table = noyau.Correspondance() + premier = noyau.nouveau_nombre(4711, self.rng, table=table) + for _ in range(20): + self.assertEqual( + noyau.nouveau_nombre(4711, self.rng, table=table), + premier, + ) + + def test_types_distincts_ne_se_confondent_pas(self): + table = noyau.Correspondance() + noyau.nouveau_nombre(5, self.rng, table=table) + noyau.nouveau_nombre(5.0, self.rng, table=table) + self.assertEqual(len(table.nombres), 2) + + +class TestPiegesDeType(unittest.TestCase): + """Les gardes, dans l'ordre où ils doivent se déclencher.""" + + def setUp(self): + self.rng = random.Random(7) + self.table = noyau.Correspondance() + self.options = _options() + + def _anon(self, valeur, **extra): + options = _options(**extra) + return noyau.anonymise_cellule(valeur, options, self.table, self.rng) + + def test_booleen_traverse_intact(self): + # isinstance(True, int) vaut True : sans garde explicite, toute + # case à cocher deviendrait un montant. + self.assertIs(self._anon(True), noyau._INTACTE) + self.assertIs(self._anon(False), noyau._INTACTE) + + def test_date_traverse_intacte(self): + for valeur in ( + datetime.datetime(2020, 1, 2, 3, 4), + datetime.date(2020, 1, 2), + datetime.time(3, 4), + ): + self.assertIs(self._anon(valeur), noyau._INTACTE) + + def test_vide_traverse_intact(self): + self.assertIs(self._anon(None), noyau._INTACTE) + self.assertIs(self._anon(""), noyau._INTACTE) + + def test_formule_traverse_intacte(self): + self.assertIs(self._anon('=IF(A1="x",1,0)'), noyau._INTACTE) + + def test_les_sept_valeurs_erreur_traversent_intactes(self): + for erreur in noyau.VALEURS_ERREUR: + self.assertIs( + self._anon(erreur), + noyau._INTACTE, + f"{erreur} doit rester intacte", + ) + + def test_il_y_a_bien_sept_valeurs_erreur(self): + self.assertEqual(len(noyau.VALEURS_ERREUR), 7) + + def test_binaire_est_vide_pas_recopie(self): + # Une colonne OLE d'Access peut porter un document entier. + self.assertIsNone(self._anon(b"\x00document")) + self.assertIsNone(self._anon(bytearray(b"\x01"))) + + def test_texte_refuse_ne_touche_rien(self): + self.assertIs(self._anon("Alpha", texte=False), noyau._INTACTE) + + def test_nombres_refuses_ne_touchent_rien(self): + self.assertIs(self._anon(42, nombres=False), noyau._INTACTE) + + +class TestNormalisationXls(unittest.TestCase): + """xlrd porte le type dans ctype, jamais dans la valeur.""" + + def test_code_23_rend_ref(self): + self.assertEqual(noyau.normaliser_xls(5, 23, 0), "#REF!") + + def test_code_0_rend_null(self): + """Le piège : « 0 reste 0 » en ferait un zéro légitime.""" + self.assertEqual(noyau.normaliser_xls(5, 0, 0), "#NULL!") + + def test_les_sept_codes_sont_couverts(self): + self.assertEqual( + set(noyau.CODES_ERREUR_XLS.values()), set(noyau.VALEURS_ERREUR) + ) + + def test_booleen_rend_un_vrai_booleen(self): + self.assertIs(noyau.normaliser_xls(4, 1, 0), True) + self.assertIs(noyau.normaliser_xls(4, 0, 0), False) + + def test_vide_rend_none(self): + self.assertIsNone(noyau.normaliser_xls(0, "", 0)) + self.assertIsNone(noyau.normaliser_xls(6, "", 0)) + + def test_texte_traverse(self): + self.assertEqual(noyau.normaliser_xls(1, "Alpha", 0), "Alpha") + + +class TestUniciteNumerique(unittest.TestCase): + """Le tirage seul collisionne, et détruit les clés. + + C'est l'intégrité que la table apporte au texte, et qu'elle refusait en + silence aux nombres : deux clés primaires distinctes recevaient le même + nombre, et la fixture ne se réimportait plus. + """ + + def test_cent_valeurs_rendent_cent_sorties(self): + table = noyau.Correspondance() + rng = random.Random(1) + sorties = [ + noyau.nouveau_nombre(v, rng, bornes=(1, 100), table=table) + for v in range(1, 101) + ] + # Mesuré avant correctif : 66 sorties distinctes sur 100. + self.assertEqual(len(set(sorties)), 100) + + def test_etendue_etroite_sans_doublon(self): + for graine in (7, 11, 42): + table = noyau.Correspondance() + rng = random.Random(graine) + sorties = [ + noyau.nouveau_nombre(v, rng, bornes=(1, 6), table=table) + for v in range(1, 7) + ] + self.assertEqual(len(set(sorties)), 6, graine) + + def test_l_elargissement_garde_le_signe(self): + table = noyau.Correspondance() + rng = random.Random(2) + sorties = [ + noyau.nouveau_nombre(-v, rng, bornes=(-50, 200), table=table) + for v in range(1, 51) + ] + self.assertEqual(len(set(sorties)), 50) + self.assertTrue(all(x < 0 for x in sorties)) + + def test_une_plage_saturee_reste_dans_ses_bornes(self): + """Élargir dès le premier échec sortait de la plage mesurée. + + Une heure de la journée devenait 189, un taux dépassait l'unité, + alors que la plage avait encore des places libres. + """ + table = noyau.Correspondance() + rng = random.Random(1) + sorties = [ + noyau.nouveau_nombre( + round(0.15 + i * 0.01, 2), + rng, + bornes=(0.15, 0.2), + table=table, + ) + for i in range(6) + ] + self.assertEqual(len(set(sorties)), 6) + for valeur in sorties: + self.assertGreaterEqual(valeur, 0.15) + # Six valeurs dans six places, l'identité interdite : la + # dernière place libre EST parfois l'identité. On grandit + # alors d'un PAS, pas d'un facteur dix — un taux reste un + # taux. + self.assertLessEqual(valeur, 0.25) + + def test_une_heure_reste_une_heure(self): + table = noyau.Correspondance() + rng = random.Random(2) + sorties = [ + noyau.nouveau_nombre(v, rng, bornes=(0, 23), table=table) + for v in range(1, 24) + ] + self.assertEqual(len(set(sorties)), 23) + self.assertLessEqual(max(sorties), 23) + + def test_aucun_nombre_n_est_rendu_a_lui_meme(self): + """Il serait compté et annoncé comme remplacé sans l'être.""" + table = noyau.Correspondance() + rng = random.Random(3) + for v in range(1, 7): + self.assertNotEqual( + noyau.nouveau_nombre(v, rng, bornes=(1, 6), table=table), v + ) + + def test_l_elargissement_ne_sert_qu_en_dernier_recours(self): + """Plus de places que de valeurs : aucune sortie hors plage.""" + table = noyau.Correspondance() + rng = random.Random(4) + sorties = [ + noyau.nouveau_nombre(v, rng, bornes=(1, 10), table=table) + for v in range(1, 10) + ] + self.assertTrue(all(1 <= x <= 10 for x in sorties), sorties) + + def test_sans_table_le_tirage_reste_borne(self): + rng = random.Random(3) + for _ in range(50): + self.assertLessEqual( + noyau.nouveau_nombre(5, rng, bornes=(1, 10)), 10 + ) + + def test_une_table_rechargee_garde_l_unicite(self): + """Le lot entier, pas seulement le fichier courant.""" + table = noyau.Correspondance(nombres={"i:1": 4, "i:2": 5}) + self.assertEqual(table.nombres_pris, {4, 5}) + rng = random.Random(4) + for v in range(3, 7): + self.assertNotIn( + noyau.nouveau_nombre(v, rng, bornes=(1, 6), table=table), + (4, 5), + ) + + +class TestMotSansRemise(unittest.TestCase): + """Deux valeurs distinctes ne peuvent PAS partager un mot.""" + + def test_le_saut_d_identite_ne_reprend_pas_un_mot_donne(self): + """Le saut avance d'un rang : il pouvait retomber sur un mot pris. + + Deux clients fusionnaient alors sur un seul mot — la RECHERCHEV + résout encore, mais sur la mauvaise ligne. + """ + table = noyau.Correspondance() + # La première valeur EST un mot du vivier : le saut se déclenche. + sorties = [ + noyau.nouveau_mot(v, table, VIVIER) + for v in (VIVIER[1], "Alpha", "Beta", VIVIER[0], "Gamma") + ] + self.assertEqual(len(set(sorties)), len(sorties)) + for source, mot in table.mots.items(): + self.assertNotEqual(source, mot) + + def test_une_table_rechargee_ne_recolle_pas(self): + premiere = noyau.Correspondance() + for v in ("Alpha", "Beta"): + noyau.nouveau_mot(v, premiere, VIVIER) + seconde = noyau.Correspondance(mots=dict(premiere.mots)) + for v in ("Gamma", "Delta"): + noyau.nouveau_mot(v, seconde, VIVIER) + self.assertEqual(len(set(seconde.mots.values())), len(seconde.mots)) + + def test_mots_pris_est_reconstruit_au_chargement(self): + table = noyau.Correspondance(mots={"Alpha": "aboulie"}) + self.assertEqual(table.mots_pris, {"aboulie"}) + + +class TestNomDeFeuilleEnConversion(unittest.TestCase): + """Une table Access porte souvent le nom du client. + + La conversion l'écrivait tel quel — nom d'onglet, clé de premier + niveau d'un JSON, nom de fichier — alors que RIEN ne le résout dans + une conversion, contrairement au classeur d'où une formule le + référence. Le tolérer aurait laissé le nom dans la copie tout en + faisant refuser le fichier au filet. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.options = {"vivier": VIVIER, "feuilles": None} + + def test_le_nom_passe_par_la_MEME_table_que_les_cellules(self): + table = noyau.Correspondance() + feuille = formats.Feuille("Alpha", [["client"], ["Alpha"]]) + nom = formats._nom_de_feuille_anonyme(feuille, table, self.options) + self.assertNotEqual(nom, "Alpha") + # La feuille et la cellule qui la nomme reçoivent le même mot. + self.assertEqual(nom, noyau.nouveau_mot("Alpha", table, VIVIER)) + + def test_sans_table_le_nom_ne_bouge_pas(self): + feuille = formats.Feuille("Alpha", [["c"], ["x"]]) + self.assertEqual( + formats._nom_de_feuille_anonyme(feuille, None, self.options), + "Alpha", + ) + + def test_la_conversion_vers_json_anonymise_la_cle(self): + table = noyau.Correspondance() + feuille = formats.Feuille("Alpha", [["c"], ["Beta"]]) + sortie = os.path.join(self.base, "o.json") + formats.convertir( + os.path.join(self.base, "s.csv"), + sortie, + "json", + self.options, + feuilles=[feuille], + table=table, + ) + arbre = json.load(open(sortie, encoding="utf-8")) + self.assertNotIn("Alpha", arbre) + + +class TestExternalIdContreLogin(unittest.TestCase): + """Un external ID et un login pointé ont la même FORME. + + « base.res_partner_7 » et « jean.tremblay » sont tous deux des jetons + minuscules pointés : la forme seule ne les sépare pas, et une colonne + « user_id » de logins passait pour une colonne de relations, donc + partait en clair. Ce qui les sépare est le PRÉFIXE : un external ID + partage son module avec ses voisins, des noms de personnes non. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _ecrire(self, contenu): + chemin = os.path.join(self.base, "e.csv") + with open(chemin, "w", encoding="utf-8") as fh: + fh.write(contenu) + return chemin + + def test_un_external_id_reste_au_plancher(self): + source = self._ecrire( + "id,partner_id/id,montant\n" + "7,base.res_partner_7,1200\n" + "8,base.res_partner_8,830\n" + ) + sortie = os.path.join(self.base, "o.csv") + formats.ecrire(source, sortie, {"graine": "3"}) + rendu = open(sortie, encoding="utf-8").read() + self.assertIn("base.res_partner_7", rendu) + + def test_un_login_pointe_est_remplace(self): + source = self._ecrire( + "id,user_id,montant\n" + "7,jean.tremblay,1200\n" + "8,marie.roy,830\n" + "9,paul.gagne,410\n" + ) + sortie = os.path.join(self.base, "o.csv") + formats.ecrire(source, sortie, {"graine": "3"}) + rendu = open(sortie, encoding="utf-8").read() + for login in ("jean.tremblay", "marie.roy", "paul.gagne"): + self.assertNotIn(login, rendu, login) + + def test_l_annonce_suit_la_portee(self): + """L'écran annonçait une colonne écartée que le moteur remplace.""" + source = self._ecrire( + "id,partner_id/id,user_id,montant\n" + "7,base.res_partner_7,jean.tremblay,1200\n" + "8,base.res_partner_8,marie.roy,830\n" + ) + apercu = formats.plan(source, {"graine": "3"}) + ecartees = {c["etiquette"] for c in apercu["colonnes_ecartees"]} + self.assertIn("partner_id/id", ecartees) + self.assertNotIn("user_id", ecartees) + + +class TestNormalisationAccess(unittest.TestCase): + """`access-parser` rend une date et un montant en CHAÎNE. + + Comme pour `.xls`, c'est le TYPE déclaré qui décide et jamais la forme + de la valeur : une colonne de texte peut légitimement porter + « 2021-12-02 00:00:00 », et la convertir la mettrait hors d'atteinte de + la règle du texte. + """ + + def test_une_date_devient_une_date(self): + valeur = noyau.normaliser_access( + "2021-12-02 00:00:00", noyau.ACCESS_DATETIME + ) + self.assertIsInstance(valeur, datetime.datetime) + self.assertEqual(valeur.year, 2021) + + def test_la_date_invalide_est_videe(self): + """Le marqueur d'Access ne porte aucune donnée.""" + self.assertIsNone( + noyau.normaliser_access("(Invalid Date)", noyau.ACCESS_DATETIME) + ) + + def test_un_montant_devient_un_nombre(self): + self.assertEqual(noyau.normaliser_access("$1,995.50", 5), 1995.50) + self.assertEqual(noyau.normaliser_access("($1,995.50)", 5), -1995.50) + + def test_un_montant_illisible_reste_du_texte(self): + self.assertEqual(noyau.normaliser_access("sur devis", 5), "sur devis") + + def test_le_texte_qui_RESSEMBLE_a_une_date_reste_du_texte(self): + """Le type décide, pas la forme.""" + self.assertEqual( + noyau.normaliser_access("2021-12-02 00:00:00", 10), + "2021-12-02 00:00:00", + ) + + def test_les_types_couverts(self): + self.assertEqual(noyau.ACCESS_DATETIME, 8) + self.assertIn(5, noyau.ACCESS_MONETAIRE) + + +class TestFiletSurUneValeurNumerique(unittest.TestCase): + """Une valeur de chiffres ne se cherche pas comme un mot. + + Sur une base ordinaire, le filet refusait seize valeurs dont aucune + ne fuyait. Un code postal « 0512 » se retrouve dans l'identifiant + 10512 ; « 1203 » est le numéro de ligne que + `` porte dans toute feuille de plus de mille lignes ; et + un nombre TIRÉ pour une colonne peut égaler, chiffre pour chiffre, une + valeur texte d'une autre. + + Refuser toute copie d'un fichier ordinaire rend le filet inutile aussi + sûrement que ne rien refuser. + """ + + def test_une_valeur_noyee_dans_un_nombre_plus_long_ne_compte_pas(self): + motif = noyau._motif_borne("0512") + self.assertFalse(motif.search("10512")) + self.assertFalse(motif.search("105120")) + self.assertFalse(motif.search("0512.7")) + + def test_une_vraie_survivance_reste_vue(self): + """Bordée de ce qui n'est pas un chiffre, elle est toujours là.""" + motif = noyau._motif_borne("0512") + for foin in ("0512", '"0512"', "a,0512,b", "0512", ">0512<"): + with self.subTest(foin=foin): + self.assertTrue(motif.search(foin)) + + def test_la_virgule_borne_un_champ_de_csv(self): + """L'exclure aveuglait le filet sur le format le plus simple, là + où un séparateur de milliers coupe déjà la suite de chiffres.""" + self.assertTrue(noyau._motif_borne("1203").search("x,1203,y")) + self.assertEqual("1,203".count("1203"), 0) + + def test_une_lettre_collee_borne_aussi(self): + """`r="A1010"` met un numéro de ligne contre une lettre de + colonne, et des chiffres collés à une lettre font un seul jeton.""" + self.assertFalse(noyau._motif_borne("1010").search('r="A1010"')) + self.assertFalse(noyau._motif_borne("1203").search("SKU1203")) + + def test_une_valeur_a_lettres_se_borne_par_les_seuls_mots(self): + """Elle se borne aussi, mais pas sur le point. + + « Document » vit dans l'URI `officeDocument` que tout classeur + écrit, et le socle du graveur ne l'excuse pas : il est MINIMAL, si + bien que chaque type de partie que la source a en plus apporte une + URI distincte de plus. Le point ne borne pas ici — il suit un mot + en fin de phrase sans en faire un autre mot. + """ + motif = noyau._motif_borne("Document") + self.assertIsNotNone(motif) + self.assertFalse(motif.search("officeDocument")) + self.assertFalse(motif.search("Documentation")) + self.assertTrue(motif.search("Document")) + self.assertTrue(motif.search("Document.")) + + def test_un_nom_dans_un_cache_ou_un_litteral_reste_trouvable(self): + """Ce que le bornage NE doit pas coûter : une survivance vraie est + bordée de balisage ou de guillemets, jamais collée à un mot.""" + motif = noyau._motif_borne("aboulie") + for foin in ( + "aboulie", + '="aboulie"', + '="Pre"&"aboulie"', + "a,aboulie,b", + "aboulie-2024", + ): + with self.subTest(foin=foin): + self.assertTrue(motif.search(foin)) + + def test_un_autre_mot_qui_contient_la_valeur_n_en_est_pas_une(self): + """« Bellevue » n'est pas une survivance de « Belle ».""" + self.assertFalse(noyau._motif_borne("Belle").search("Bellevue")) + + def test_les_attributs_ne_portent_pas_de_valeur_numerique(self): + """Un XML porte ses index dans les ATTRIBUTS et ses valeurs de + cellule dans les nœuds de texte : la matière est séparée.""" + brut = '7' + _e, _n, texte, _tn = noyau._chaines_distinctes(brut) + self.assertIn("7", texte) + self.assertNotIn("1203", texte) + self.assertNotIn("A1203", texte) + + def test_une_partie_plate_reste_fouillee_ENTIERE(self): + """Les deux derniers blocs valent les deux premiers hors XML : un + champ de csv est de la donnée où qu'il soit.""" + blocs = noyau._matiere("o.csv", "a,0512,b", reductible=False) + self.assertEqual(len(blocs), 4) + self.assertEqual(blocs[0], blocs[2]) + self.assertEqual(blocs[1], blocs[3]) + + def test_un_nombre_tire_est_tolere_comme_un_mot_l_est(self): + """Sa présence est expliquée par le tirage, non par une + survivance : un identifiant tiré à 10785 et un code postal + « 10785 » sont les mêmes chiffres pour des raisons différentes.""" + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base, True) + copie = os.path.join(base, "o.csv") + with open(copie, "w", encoding="utf-8") as flux: + flux.write("aboulie,10785\n") + table = noyau.Correspondance() + table.mots["10785"] = "aboulie" + table.nombres[42] = 10785 + fuites, _non = noyau.verifier_copie([copie], table) + self.assertEqual(fuites, {}) + + def test_l_identite_d_un_nombre_reste_un_refus(self): + """Un nombre rendu à lui-même est annoncé remplacé et ne l'est + pas : c'est le cas que la tolérance ne doit PAS couvrir.""" + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base, True) + copie = os.path.join(base, "o.csv") + with open(copie, "w", encoding="utf-8") as flux: + flux.write("10785,x\n") + table = noyau.Correspondance() + table.mots["10785"] = "aboulie" + table.nombres[10785] = 10785 + fuites, _non = noyau.verifier_copie([copie], table) + self.assertIn("10785", fuites) + + +class TestResolutionDeLaColonne(unittest.TestCase): + """L'intégralité vient de la COLONNE, non du type d'une valeur. + + `isinstance(valeur, int)` ne peut pas en répondre : le format `.xls` + ne stocke que des doubles, si bien que son lecteur rend 100 en + « 100.0 ». Toute colonne d'entiers d'un `.xls` ressortait donc + décimale, et la copie ne se réimportait plus dans un champ entier. + """ + + def setUp(self): + self.rng = random.Random(7) + + def test_une_colonne_entiere_rend_des_entiers(self): + """Même quand le lecteur a rendu des flottants.""" + for valeur in (100.0, 256.0, 101.0): + with self.subTest(valeur=valeur): + tire = noyau.nouveau_nombre( + valeur, self.rng, bornes=(100.0, 256.0, True) + ) + self.assertTrue(float(tire).is_integer(), tire) + + def test_une_colonne_decimale_garde_ses_decimales(self): + vus = [ + noyau.nouveau_nombre( + v, random.Random(v), bornes=(0.02, 1007.64, False) + ) + for v in (32.38, 47.42, 126.38) + ] + self.assertFalse(all(float(v).is_integer() for v in vus), vus) + + def test_sans_troisieme_terme_le_type_decide_encore(self): + """Les bornes à deux termes restent valides : la fonction sert + aussi hors du parcours de colonnes.""" + self.assertTrue( + float( + noyau.nouveau_nombre(5, self.rng, bornes=(1, 100)) + ).is_integer() + ) + self.assertIsInstance( + noyau.nouveau_nombre(5.5, self.rng, bornes=(1.0, 100.0)), float + ) + + def test_la_cle_de_la_table_ne_depend_PAS_de_la_colonne(self): + """Sinon un même nombre vu dans deux colonnes de résolutions + différentes recevrait deux remplacements, et la jointure qui les + relie se désagrégerait.""" + table = noyau.Correspondance() + premier = noyau.nouveau_nombre( + 10248, random.Random(1), bornes=(10248, 11077, True), table=table + ) + second = noyau.nouveau_nombre( + 10248, random.Random(2), bornes=(1, 99999, False), table=table + ) + self.assertEqual(premier, second) + + def test_une_seule_decimale_suffit_a_decimaliser_la_colonne(self): + """La mesure porte sur TOUTE la colonne.""" + feuille = formats.Feuille( + "F", [["montant"], [1.0], [2.0], [3.5], [4.0]] + ) + colonne = formats._stats_colonnes(feuille)[0] + self.assertFalse(colonne["entiere"]) + entiere = formats._stats_colonnes( + formats.Feuille("F", [["m"], [1.0], [2.0], [3.0]]) + )[0] + self.assertTrue(entiere["entiere"]) + + def test_une_colonne_sans_nombre_n_est_pas_dite_entiere(self): + """Vraie par vacuité, elle aurait forcé un arrondi ailleurs.""" + colonne = formats._stats_colonnes( + formats.Feuille("F", [["nom"], ["aboulie"], ["acai"]]) + )[0] + self.assertFalse(colonne["entiere"]) + + +class TestEchelleMonetaireAccess(unittest.TestCase): + """`access-parser` a DEUX sorties pour une colonne monétaire. + + Reconnaît-il le format déclaré de la colonne, il place le point décimal + et rend « $14.00 ». Ne le reconnaît-il pas, il rend l'entier de + stockage TEL QUEL — et Access garde un Currency en entier multiplié par + dix mille. Les deux formes cohabitent dans un MÊME fichier : une + colonne de prix en sort juste et une colonne de fret gonflée de quatre + ordres de grandeur. + + Le point décimal tranche sans deviner : toutes les branches de la + bibliothèque qui aboutissent en insèrent un, celle qui renonce n'en + met pas. + """ + + def _cas(self, brut, attendu): + """Éprouvé sur CHAQUE code monétaire, non sur un seul.""" + for code in sorted(noyau.ACCESS_MONETAIRE): + with self.subTest(code=code, brut=brut): + rendu = noyau.normaliser_access(brut, code) + self.assertAlmostEqual(rendu, attendu, places=6) + + def test_l_entier_de_stockage_est_divise(self): + self._cas("474200", 47.42) + self._cas("1263800", 126.38) + self._cas("54500", 5.45) + self._cas("0", 0.0) + + def test_la_forme_localisee_ne_l_est_pas(self): + self._cas("$14.00", 14.0) + self._cas("$1,995.50", 1995.5) + self._cas("\u20ac5.00", 5.0) + + def test_le_signe_survit_aux_deux_formes(self): + """Access écrit un négatif entre parenthèses.""" + self._cas("(474200)", -47.42) + self._cas("-474200", -47.42) + + def test_l_exposant_n_est_pas_ampute(self): + """La branche scientifique rend « 3.24e+01 » : retirer le « e » en + faisait 3,2401, soit un facteur dix sur la borne de la colonne.""" + self._cas("3.24e+01", 32.4) + self._cas("3.24e-01", 0.324) + + def test_le_pourcentage_garde_son_echelle(self): + """Sa branche coupe deux chiffres, pas quatre, et pose le point.""" + self._cas("12.34%", 12.34) + + +class TestCoercition(unittest.TestCase): + """Un champ CSV ou un attribut XML arrive sans type.""" + + def test_nombres_reconnus(self): + self.assertEqual(noyau.coercer_texte("4711"), 4711) + self.assertEqual(noyau.coercer_texte("-320"), -320) + self.assertEqual(noyau.coercer_texte("0.15"), 0.15) + self.assertEqual(noyau.coercer_texte("0"), 0) + + def test_zeros_de_tete_restent_du_texte(self): + # « 007 » est un code, pas une quantité. + self.assertEqual(noyau.coercer_texte("007"), "007") + + def test_ce_que_float_accepterait_a_tort(self): + for texte in ("1e5", "NaN", "inf", "-inf", "1,5", " 1 2 "): + self.assertEqual(noyau.coercer_texte(texte), texte) + + def test_non_chaine_traverse(self): + self.assertEqual(noyau.coercer_texte(5), 5) + self.assertIsNone(noyau.coercer_texte(None)) + + +class TestVivierEtMots(unittest.TestCase): + """L'attribution est indexée, jamais tirée.""" + + def test_vivier_mesure(self): + # 1404 mots dans randomwordfr, 1366 après translittération des + # accents et rejet de ce qui n'est pas un mot simple. + self.assertEqual(len(VIVIER), 1366) + + def test_vivier_sans_accent_ni_espace(self): + for mot in VIVIER: + self.assertRegex(mot, r"^[a-z_]+$") + + def test_vivier_trie_et_dedoublonne(self): + # TRIÉ : l'ordre d'un set varie d'un processus à l'autre, et une + # table réutilisée rendrait d'autres mots pour les mêmes valeurs. + self.assertEqual(list(VIVIER), sorted(VIVIER)) + self.assertEqual(len(VIVIER), len(set(VIVIER))) + + def test_deux_appels_rendent_la_meme_sequence(self): + self.assertEqual(noyau.vivier_de_mots(), noyau.vivier_de_mots()) + + def test_meme_source_meme_mot(self): + table = noyau.Correspondance() + premier = noyau.nouveau_mot("Alpha", table, VIVIER) + self.assertEqual(noyau.nouveau_mot("Alpha", table, VIVIER), premier) + + def test_valeurs_distinctes_rendent_mots_distincts(self): + """La propriété que le tirage ne donnait pas. + + Sur 20 mots tirés au hasard, six valeurs distinctes ont déjà 56 % + de chance d'en partager un, et deux clients qui partagent un mot + fusionnent en une seule clé. + """ + table = noyau.Correspondance() + sorties = { + noyau.nouveau_mot(f"valeur-{i}", table, VIVIER) + for i in range(len(VIVIER) + 1) + } + self.assertEqual(len(sorties), len(VIVIER) + 1) + + def test_au_dela_du_vivier_ce_sont_des_PAIRES(self): + table = noyau.Correspondance() + for i in range(len(VIVIER) + 3): + dernier = noyau.nouveau_mot(f"v{i}", table, VIVIER) + self.assertIn("_", dernier) + self.assertFalse(dernier.startswith("mot_")) + gauche, droite = dernier.split("_", 1) + self.assertIn(gauche, VIVIER) + self.assertIn(droite, VIVIER) + + def test_repli_a_vingt_mots_annonce(self): + capacites = formats.capabilities() + self.assertIn("mots", capacites) + + +class TestGraine(unittest.TestCase): + def test_meme_graine_meme_passage(self): + sorties = [] + for _ in range(2): + rng = random.Random(42) + table = noyau.Correspondance() + sorties.append( + [ + noyau.anonymise_cellule(v, _options(), table, rng) + for v in (10, -10, "Alpha", 3.5, "Beta", 77) + ] + ) + self.assertEqual(sorties[0], sorties[1]) + + +class TestPortee(unittest.TestCase): + """La portée se décide sur les coordonnées, jamais sur la valeur.""" + + def test_ligne_un_hors_portee_par_defaut(self): + self.assertFalse(noyau.cellule_en_portee("F", 1, 1, _options())) + self.assertTrue( + noyau.cellule_en_portee("F", 1, 1, _options(entetes=True)) + ) + + def test_feuille_non_listee_hors_portee(self): + options = _options(feuilles=["Autre"]) + self.assertFalse(noyau.cellule_en_portee("F", 2, 1, options)) + self.assertTrue(noyau.cellule_en_portee("Autre", 2, 1, options)) + + def test_colonne_exclue_sur_toutes_ses_lignes(self): + options = _options( + etiquettes={("F", 2): "montant"}, + colonnes_intactes={"montant"}, + ) + for ligne in (2, 3, 900): + self.assertFalse(noyau.cellule_en_portee("F", ligne, 2, options)) + + def test_colonne_par_index_en_repli(self): + options = _options(colonnes_intactes={"3"}) + self.assertFalse(noyau.cellule_en_portee("F", 2, 3, options)) + + +class TestPlancherStructurel(unittest.TestCase): + """anonymize.py refuse par le NOM avant de lire une valeur.""" + + def test_id_nu(self): + self.assertTrue(noyau.colonne_plancher("id")) + self.assertTrue(noyau.colonne_plancher("ID")) + + def test_suffixes_structurels_au_nom_seul(self): + """« /id » et « /.id » ne portent JAMAIS de texte libre.""" + for etiquette in ("partner_id/id", "partner_id/.id"): + self.assertTrue(noyau.colonne_plancher(etiquette), etiquette) + + def test_suffixe_de_relation_exige_la_forme_d_une_CIBLE(self): + """Un export import-compatible met un NOM dans « partner_id ». + + Et un mot en minuscules — un login, un nom de service — n'est pas + la cible d'une relation : l'exiger seulement « identifiant » + recopiait « jtremblay » et « comptabilite » en clair. + """ + for etiquette in ("partner_id", "partner_ids"): + self.assertFalse(noyau.colonne_plancher(etiquette), etiquette) + self.assertFalse( + noyau.colonne_plancher(etiquette, forme_identifiant=True), + etiquette, + ) + self.assertTrue( + noyau.colonne_plancher(etiquette, forme_relation=True), + etiquette, + ) + + def test_champs_structurels_au_nom_seul(self): + for etiquette in ("sequence", "active", "create_uid"): + self.assertTrue(noyau.colonne_plancher(etiquette), etiquette) + + def test_une_selection_exige_AUSSI_peu_de_valeurs(self): + """La forme seule accepte n'importe quel mot minuscule. + + Une colonne de provinces, ou de créneaux nommés par des + personnes, passait pour une sélection sur ce seul fait. + """ + self.assertFalse( + noyau.colonne_plancher("state", forme_identifiant=True) + ) + self.assertTrue( + noyau.colonne_plancher( + "state", forme_identifiant=True, selection=True + ) + ) + + def test_une_etiquette_pointee_exige_la_preuve_d_un_external_id(self): + """« key », « model », « res_model » portent un jeton pointé. + + La forme d'un mot minuscule ne suffit pas : une colonne « Key » de + identifiants de personnes en minuscules partait en clair. + """ + for etiquette in ("key", "model", "res_model", "arch_db"): + self.assertFalse( + noyau.colonne_plancher(etiquette, forme_identifiant=True), + etiquette, + ) + self.assertTrue( + noyau.colonne_plancher(etiquette, forme_relation=True), + etiquette, + ) + + def test_un_external_id_se_PROUVE(self): + """Compter les préfixes communs ne tranchait pas. + + Une équipe entière de logins partage son domaine, et une colonne à + une seule valeur n'a aucun préfixe à comparer. Ce qui prouve un + external ID est le NUMÉRO de son local, ou le module sentinelle de + l'export. + """ + for valeur in ( + "base.res_partner_7", + "__export__.res_partner_42", + "__import__.sale_order_1", + ): + self.assertTrue(noyau.valeur_forme_relation(valeur), valeur) + for valeur in ( + "jean.tremblay", + "tremblay.jean", + "clinique.exemple.com", + "account.move", + ): + self.assertFalse(noyau.valeur_forme_relation(valeur), valeur) + + def test_une_liste_de_relations_prouve_chaque_membre(self): + self.assertTrue(noyau.valeur_forme_relation("base.tag_1,base.tag_2")) + self.assertFalse( + noyau.valeur_forme_relation("base.tag_1,jean.tremblay") + ) + + def test_display_name_n_est_JAMAIS_au_plancher(self): + """Dans un fichier plat, cette colonne EST la donnée. + + Le serveur la recalcule depuis `name`, ce qui la rend structurelle + dans une base ; un fichier ne recalcule rien. Aucune forme mesurée + ne doit la sauver. + """ + for forme in (False, True): + self.assertFalse( + noyau.colonne_plancher( + "display_name", + forme_identifiant=forme, + forme_relation=forme, + ), + forme, + ) + + def test_forme_relation_est_plus_etroite(self): + """Un login ou un nom de service n'est pas une cible de relation.""" + for valeur in (7, 7.0, "", "42/7/", "base.res_partner_7"): + self.assertTrue(noyau.valeur_forme_relation(valeur), repr(valeur)) + for valeur in ("jtremblay", "comptabilite", "Paie_Zeta.pdf"): + self.assertFalse(noyau.valeur_forme_relation(valeur), repr(valeur)) + # Et le nom de fichier n'est pas non plus un identifiant. + self.assertFalse(noyau.valeur_forme_identifiant("Paie_Zeta.pdf")) + + def test_forme_identifiant(self): + for valeur in ( + 7, + 7.0, + "", + "base.res_partner_7", + "sale", + "1/2/", + "base.p1,base.p2", + ): + self.assertTrue( + noyau.valeur_forme_identifiant(valeur), repr(valeur) + ) + for valeur in ( + "Boulangerie Tremblay inc.", + "Jean Tremblay", + "Quebec", + "Freightliner M2 106", + 7.5, + ): + self.assertFalse( + noyau.valeur_forme_identifiant(valeur), repr(valeur) + ) + + def test_une_colonne_ordinaire_passe(self): + for etiquette in ("montant", "nom", "identifiant_client", ""): + self.assertFalse(noyau.colonne_plancher(etiquette), etiquette) + + def test_plancher_actif_avec_les_reponses_par_defaut(self): + """Appuyer sur Entrée ne doit pas détruire partner_id/id.""" + options = _options(etiquettes={("F", 1): "partner_id/id"}) + self.assertFalse(noyau.cellule_en_portee("F", 2, 1, options)) + + +class TestSerialisationHorsTableur(unittest.TestCase): + def test_dates_en_iso(self): + self.assertEqual( + noyau.valeur_hors_tableur(datetime.date(2020, 1, 2)), + "2020-01-02", + ) + + def test_none_reste_none(self): + self.assertIsNone(noyau.valeur_hors_tableur(None)) + + def test_objet_a_texte_rend_son_texte(self): + """Un ArrayFormula ne définit pas __str__ : un csv.writer naïf + graverait son adresse mémoire dans le fichier.""" + + class FausseFormule: + text = "=SUM(A1:A2)" + + self.assertEqual( + noyau.valeur_hors_tableur(FausseFormule()), "=SUM(A1:A2)" + ) + + def test_booleen_et_erreur_traversent(self): + self.assertIs(noyau.valeur_hors_tableur(True), True) + self.assertEqual(noyau.valeur_hors_tableur("#REF!"), "#REF!") + + +class TestFormatDeNombre(unittest.TestCase): + """Le texte libre d'un format, séparé du motif qui l'entoure. + + Excel porte du texte dans un format de quatre façons, dont une SANS + guillemets ni barre oblique, et traduit ses marques de position dans + la langue du classeur. Reconnaître le libellé sans abîmer le motif + n'a donc rien d'un test sur les guillemets : chaque forme ci-dessous + tranche un cas où l'une des deux moitiés a été perdue. + """ + + @staticmethod + def _mot_si_long(interieur): + """Le contrat de mot_si_long : None sous le seuil du filet.""" + if len(interieur.strip()) < noyau.LONGUEUR_VERIFIABLE: + return None + return "MOT" + + def _rendu(self, fmt): + return formats._parcourir_format(fmt, self._mot_si_long) + + def test_motifs_traversent_intacts(self): + """Un motif ne porte pas de donnée : le toucher abîme la copie. + + Les formes en lettres de locale — « jj/mm/aaaa » en français, + « tt.mm.jjjj » en allemand, « gg/mm/aaaa » en italien — sont des + dates au même titre que « dd/mm/yyyy ». Celles sans séparateur + atteignent le seuil du filet à elles seules. + """ + for fmt in ( + "0.00%", + "General", + "@", + "0.00E+00", + "# ??/??", + "dd/mm/yyyy hh:mm:ss", + "jj/mm/aaaa", + "tt.mm.jjjj", + "aaaa-mm-jj", + "gg/mm/aaaa", + "yyyymmdd", + "aaaammjj", + "hhmm", + "mmss", + "h:mm AM/PM", + "mmm-yy", + "jjjj jj mmmm aaaa", + "0.00_);[Red](0.00)", + "[Red]#,##0;-#,##0", + "[h]:mm:ss", + "[<=9999999]000-0000;000-000-0000", + "_-* #,##0.00_-;-* #,##0.00_-", + "0.00;;", + "\u00a5#,##0.00", + ): + with self.subTest(fmt=fmt): + self.assertEqual(self._rendu(fmt), fmt) + + def test_balise_de_locale_traverse_intacte(self): + """Une section de devise dont TOUT est de convention. + + Le seul LCID hexadécimal ne les couvre pas, et les remplacer + détruit le symbole monétaire de la copie ou la forme de ses + dates. + """ + for fmt in ( + "[$-en-US]jj/mm/aaaa", + "[$-x-sysdate]", + "[$\u20ac-x-euro2]#,##0", + "[$-409]#,##0", + "[$R$-pt-BR]#,##0.00", + ): + with self.subTest(fmt=fmt): + self.assertEqual(self._rendu(fmt), fmt) + + def test_libelles_partent_par_leurs_quatre_ecritures(self): + """Guillemets, barre oblique, section de devise, et texte nu.""" + for fmt, libelle in ( + ('#,##0" aboulie"', "aboulie"), + ("#,##0.00\\a\\b\\o\\u\\l", "aboul"), + ("[$aboulie-409]#,##0", "aboulie"), + ("#,##0 aboulie", "aboulie"), + ): + with self.subTest(fmt=fmt): + rendu = self._rendu(fmt) + self.assertNotIn(libelle, rendu) + self.assertIn("MOT", rendu) + + def test_le_motif_survit_au_libelle_nu(self): + """Le remplacement porte sur le LIBELLÉ, pas sur toute la suite. + + Un libellé nu se lit dans la même course de texte que le motif + qui le précède : remplacer la course entière rendait « "MOT" » + seul, et la copie perdait sa forme numérique. + """ + self.assertEqual(self._rendu("#,##0 aboulie"), '#,##0 "MOT"') + self.assertEqual(self._rendu("0.0 aboulie %"), '0.0 "MOT" %') + self.assertEqual( + self._rendu("#,##0 aboulie;-#,##0 aboulie"), + '#,##0 "MOT";-#,##0 "MOT"', + ) + self.assertEqual( + self._rendu("aboulie jj/mm/aaaa aboulie"), + '"MOT" jj/mm/aaaa "MOT"', + ) + self.assertEqual( + self._rendu("aboulie 0.00E+00 aboulie"), + '"MOT" 0.00E+00 "MOT"', + ) + + def test_mots_joints_par_un_espace_font_un_libelle(self): + """« Nom du client » est UN libellé, pas trois mots à remplacer + un à un : chacun pris seul retombe sous le seuil du filet.""" + self.assertEqual(self._rendu("#,##0 Nom du client"), '#,##0 "MOT"') + + def test_mot_et_marque_se_distinguent_par_la_repetition(self): + """Une marque vient par groupes d'une même lettre, un mot non.""" + for marque in ("aaaa", "mm", "jjjj", "hhmm", "yyyymmdd", "General"): + with self.subTest(marque=marque): + self.assertFalse(formats._est_un_mot(marque)) + for mot in ("aboulie", "Nom", "client", "Total", "ZQXNU"): + with self.subTest(mot=mot): + self.assertTrue(formats._est_un_mot(mot)) + + def test_les_jetons_entre_crochets_traversent_intacts(self): + """La grammaire d'Excel ÉNUMÈRE ce qu'un crochet peut porter. + + C'est une spécification, non une devinette : l'énumération est + légitime ici là où l'alphabet des marques de date a été rejeté. + Chaque forme documentée est reprise, car en oublier une casse le + code de format de la copie. + """ + for fmt in ( + "[Red]#,##0", + "[Green]0.00", + "[Black]0", + "[White]0", + "[Blue]0", + "[Cyan]0", + "[Magenta]0", + "[Yellow]0", + "[Color12]0", + "[COLOR 3]0", + "[color56]0", + "[DBNum1]0", + "[NatNum12]0", + "[h]:mm", + "[mm]:ss", + "[<100]0;[>=100]0.0", + "[<>1]0", + "[ENG]jj/mm/aaaa", + "[THAI]0", + "[t]0", + ): + with self.subTest(fmt=fmt): + self.assertEqual(self._rendu(fmt), fmt) + + def test_une_section_hors_grammaire_porte_du_texte_libre(self): + """Excel n'écrit pas une telle section, un producteur tiers si. + + Le remplacement reste NU : un crochet ne porte pas de guillemets, + et la section était déjà hors grammaire avant qu'on y touche. + """ + self.assertEqual(self._rendu("[aboulie]#,##0"), "[MOT]#,##0") + self.assertEqual(self._rendu("[Nom du client]0.00"), "[MOT]0.00") + + def test_un_crochet_non_ferme_ne_boucle_pas(self): + """`find` rend -1, et l'index repartait à zéro.""" + self.assertEqual(self._rendu("#,##0[aboulie"), "#,##0[aboulie") + + def test_libelle_de_devise_non_conventionnel_reste_un_libelle(self): + """« [$Cabinet-Lav] » ressemble à une balise de locale et n'en + est pas : son texte est libre, et un nom y tient.""" + rendu = self._rendu("[$aboulie-Lav]#,##0") + self.assertNotIn("aboulie", rendu) + + +class TestNomDeFichier(unittest.TestCase): + def test_separateur_remplace(self): + pris = set() + self.assertNotIn("/", noyau.nom_de_fichier_sur("Ventes/2024", pris)) + + def test_nom_vide_numerote(self): + pris = set() + self.assertTrue( + noyau.nom_de_fichier_sur("///", pris).startswith("feuille_") + ) + + def test_deux_noms_reduits_au_meme_se_distinguent(self): + pris = set() + premier = noyau.nom_de_fichier_sur("a/b", pris) + second = noyau.nom_de_fichier_sur("a:b", pris) + self.assertNotEqual(premier, second) + + +class TestPorteEntree(unittest.TestCase): + """Refuser avant qu'une bibliothèque rapporte une exception.""" + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _fichier(self, nom, octets=b"x"): + chemin = os.path.join(self.base, nom) + with open(chemin, "wb") as fh: + fh.write(octets) + return chemin + + def test_repertoire_refuse(self): + self.assertEqual(noyau.verifier_source(self.base), "pas_un_fichier") + + def test_absent_refuse(self): + self.assertEqual( + noyau.verifier_source(os.path.join(self.base, "rien")), + "pas_un_fichier", + ) + + def test_taille_nulle_refusee(self): + self.assertEqual( + noyau.verifier_source(self._fichier("vide.csv", b"")), "vide" + ) + + def test_fichier_ordinaire_accepte(self): + self.assertIsNone( + noyau.verifier_source(self._fichier("bon.csv", b"a,b\n")) + ) + + def test_detect_format_par_les_octets(self): + ole2 = self._fichier( + "menteur.xlsx", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1rest" + ) + # Un OLE2 sous une extension OOXML : protégé ou non conforme, + # jamais passé à openpyxl, qui rendrait le même BadZipFile pour un + # classeur chiffré et pour un fichier corrompu. + self.assertEqual(noyau.detect_format(ole2), "protege") + + vrai_xls = self._fichier( + "vieux.xls", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1rest" + ) + self.assertEqual(noyau.detect_format(vrai_xls), "xls") + + def test_divergence_signalee(self): + html = self._fichier("export.xls", b"t") + self.assertTrue( + noyau.format_divergent(html, noyau.detect_format(html)) + ) + + def test_has_macros_ne_leve_sur_rien(self): + """.xlsb passe par cette fonction et par elle seule.""" + for nom, octets in ( + ("ole.xlsb", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"), + ("tronque.xlsx", b"PK\x03\x04tronque"), + ("texte.xlsx", b"pas un zip"), + ): + self.assertFalse(noyau.has_macros(self._fichier(nom, octets)), nom) + + def test_has_macros_vrai_sur_un_zip_qui_en_porte(self): + chemin = os.path.join(self.base, "avec.xlsm") + with zipfile.ZipFile(chemin, "w") as z: + z.writestr("xl/vbaProject.bin", b"\x00") + self.assertTrue(noyau.has_macros(chemin)) + + +class TestBoutEnBoutStdlib(unittest.TestCase): + """CSV, JSON et XML : aucun openpyxl, donc lançables partout.""" + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _ecrire(self, nom, contenu): + chemin = os.path.join(self.base, nom) + with open(chemin, "w", encoding="utf-8") as fh: + fh.write(contenu) + return chemin + + def test_csv_bout_en_bout(self): + source = self._ecrire( + "clients.csv", + "id,nom,montant,taux\n1,Alpha,4711,0.15\n" + "2,Beta,-320,0.20\n3,Alpha,4711,0.15\n", + ) + sortie = os.path.join(self.base, "out.csv") + bilan = formats.ecrire(source, sortie, {"graine": "7"}) + lignes = [ + l.split(",") + for l in open(sortie, encoding="utf-8").read().splitlines() + ] + # L'en-tête est intact par défaut. + self.assertEqual(lignes[0], ["id", "nom", "montant", "taux"]) + # Le plancher protège « id ». + self.assertEqual([l[0] for l in lignes[1:]], ["1", "2", "3"]) + # Le même client rend le même mot dans les deux lignes. + self.assertEqual(lignes[1][1], lignes[3][1]) + self.assertNotEqual(lignes[1][1], lignes[2][1]) + # AUCUNE valeur texte de la source ne survit. Sans cette + # assertion, un graveur inerte garde toutes les précédentes : + # elles sont vraies des valeurs SOURCE aussi. + texte = open(sortie, encoding="utf-8").read() + self.assertNotIn("Alpha", texte) + self.assertNotIn("Beta", texte) + # Un montant reste un nombre, du même signe. + self.assertGreater(float(lignes[1][2]), 0) + self.assertLess(float(lignes[2][2]), 0) + # Le taux reste dans l'étendue mesurée de sa colonne. + for ligne in lignes[1:]: + self.assertLessEqual(float(ligne[3]), 0.20) + self.assertGreaterEqual(float(ligne[3]), 0.15) + self.assertGreater(bilan["remplacees"], 0) + + def test_csv_la_source_n_est_pas_modifiee(self): + source = self._ecrire("s.csv", "a,b\n1,Alpha\n") + avant = (os.path.getmtime(source), os.path.getsize(source)) + formats.ecrire( + source, os.path.join(self.base, "o.csv"), {"graine": "1"} + ) + self.assertEqual( + avant, (os.path.getmtime(source), os.path.getsize(source)) + ) + + def test_csv_une_seule_colonne_ne_leve_pas(self): + """csv.Sniffer lève sur cette forme, la plus courante ici.""" + source = self._ecrire("noms.csv", "nom\nAlpha\nBeta\nGamma\n") + rapport = formats.report(source) + self.assertEqual(rapport["delimiteur"], ",") + self.assertEqual(rapport["delimiteur_source"], "repli") + + def test_csv_dents_de_scie_ne_leve_pas(self): + source = self._ecrire("scie.csv", "a,b,c\n1,2\n3,4,5,6\n") + rapport = formats.report(source) + self.assertTrue(rapport["feuilles"]) + + def test_csv_encodage_sans_chardet(self): + chemin = os.path.join(self.base, "bom.csv") + with open(chemin, "wb") as fh: + fh.write("a,b\n1,Alpha\n".encode("utf-8")) + rapport = formats.report(chemin) + self.assertEqual(rapport["encodage_source"], "bom") + self.assertEqual(rapport["encodage"], "utf-8-sig") + + def test_json_cles_preservees_valeurs_anonymisees(self): + source = self._ecrire( + "d.json", + json.dumps( + [ + {"id": 1, "nom": "Alpha", "montant": 4711}, + {"id": 2, "nom": "Beta", "montant": -320}, + ] + ), + ) + sortie = os.path.join(self.base, "o.json") + formats.ecrire(source, sortie, {"graine": "3"}) + arbre = json.load(open(sortie, encoding="utf-8")) + self.assertEqual(sorted(arbre[0].keys()), ["id", "montant", "nom"]) + self.assertNotEqual(arbre[0]["nom"], "Alpha") + self.assertGreater(arbre[0]["montant"], 0) + self.assertLess(arbre[1]["montant"], 0) + + def test_xml_texte_et_attributs_par_la_meme_table(self): + source = self._ecrire( + "d.xml", + 'Alpha' + "4711", + ) + sortie = os.path.join(self.base, "o.xml") + formats.ecrire(source, sortie, {"graine": "5"}) + import xml.etree.ElementTree as ET + + racine = ET.parse(sortie).getroot() + ligne = racine.find("ligne") + # Balises et noms d'attribut sont de la structure et restent. + self.assertEqual(racine.tag, "racine") + self.assertIn("ref", ligne.attrib) + # Le MÊME identifiant en attribut et en texte rend le même mot, + # sinon la jointure entre les deux casse. + self.assertEqual(ligne.attrib["ref"], ligne.find("nom").text) + self.assertNotEqual(ligne.attrib["ref"], "Alpha") + + def test_plan_n_ecrit_rien(self): + source = self._ecrire("p.csv", "a,b\n1,Alpha\n") + avant = (os.path.getmtime(source), os.path.getsize(source)) + apercu = formats.plan(source, {"graine": "1"}) + self.assertEqual( + avant, (os.path.getmtime(source), os.path.getsize(source)) + ) + self.assertIn("remplacees", apercu) + self.assertIn("colonnes_ecartees", apercu) + + def test_destination_egale_source_refusee(self): + source = self._ecrire("s.csv", "a,b\n1,Alpha\n") + avant = open(source, encoding="utf-8").read() + with self.assertRaises(formats.ErreurMoteur) as capture: + formats.ecrire(source, source, {"graine": "1"}) + self.assertEqual(capture.exception.cle, "destination_source") + self.assertEqual(open(source, encoding="utf-8").read(), avant) + + def test_destination_lien_vers_la_source_refusee(self): + source = self._ecrire("s.csv", "a,b\n1,Alpha\n") + lien = os.path.join(self.base, "lien.csv") + os.symlink(source, lien) + with self.assertRaises(formats.ErreurMoteur) as capture: + formats.ecrire(source, lien, {"graine": "1"}) + self.assertEqual(capture.exception.cle, "destination_source") + + def test_feuille_inconnue_refusee(self): + source = self._ecrire("s.csv", "a,b\n1,Alpha\n") + with self.assertRaises(formats.ErreurMoteur) as capture: + formats.ecrire( + source, + os.path.join(self.base, "o.csv"), + {"feuilles": ["Introuvable"]}, + ) + self.assertEqual(capture.exception.cle, "aucune_feuille") + + def test_aucun_temporaire_ne_subsiste_apres_un_echec(self): + cible = os.path.join(self.base, "sortie", "o.csv") + + def graveur_qui_echoue(_chemin): + raise OSError("disque plein") + + with self.assertRaises(OSError): + formats._ecrire_atomique(cible, graveur_qui_echoue) + restes = [ + n + for n in os.listdir(os.path.dirname(cible)) + if n.startswith(".transform-") + ] + self.assertEqual(restes, []) + + def test_repertoire_de_destination_non_vide_refuse(self): + plein = os.path.join(self.base, "plein") + os.makedirs(plein) + open(os.path.join(plein, "deja"), "w").close() + with self.assertRaises(formats.ErreurMoteur) as capture: + formats._preparer_repertoire(plein) + self.assertEqual(capture.exception.cle, "repertoire_non_vide") + + def test_table_portable_entre_deux_fichiers(self): + """Le même client doit rendre le même mot dans tout un lot. + + L'attribution étant indexée par ordre de première rencontre, deux + fichiers listant les mêmes clients dans un ORDRE DIFFÉRENT leur + donneraient des mots différents sans table partagée. + """ + premier = self._ecrire("a.csv", "nom\nAlpha\nBeta\n") + second = self._ecrire("b.csv", "nom\nBeta\nAlpha\n") + table = os.path.join(self.base, "t.json") + formats.ecrire( + premier, + os.path.join(self.base, "a.out.csv"), + {"table_chemin": table}, + ) + formats.ecrire( + second, + os.path.join(self.base, "b.out.csv"), + {"table_chemin": table}, + ) + lus = {} + for nom, fichier in ( + ("a", "a.out.csv"), + ("b", "b.out.csv"), + ): + lignes = ( + open(os.path.join(self.base, fichier), encoding="utf-8") + .read() + .splitlines()[1:] + ) + lus[nom] = lignes + # a.csv liste Alpha puis Beta ; b.csv l'inverse. + self.assertEqual(lus["a"][0], lus["b"][1]) + self.assertEqual(lus["a"][1], lus["b"][0]) + + def test_la_table_est_en_0600(self): + source = self._ecrire("s.csv", "nom\nAlpha\n") + table = os.path.join(self.base, "t.json") + formats.ecrire( + source, + os.path.join(self.base, "o.csv"), + {"table_chemin": table}, + ) + self.assertEqual(os.stat(table).st_mode & 0o777, 0o600) + + def test_conversion_csv_vers_json(self): + source = self._ecrire("s.csv", "nom,n\nAlpha,5\n") + sortie = os.path.join(self.base, "o.json") + formats.ecrire(source, sortie, {"conversion": "json", "graine": "1"}) + # `assertTrue(arbre)` passait sur une recopie verbatim. + self.assertNotIn("Alpha", open(sortie, encoding="utf-8").read()) + + def test_aucune_ligne_ne_se_perd_a_la_conversion(self): + """Le compte de « remplacées » et le compte d'enregistrements + doivent parler du même fichier. + + La mesure ne place jamais l'en-tête sous une ligne de données ; + l'opérateur, lui, peut le DÉSIGNER, et sa réponse ne se remesure + pas. La ligne 1 est alors hors de l'empan, et partir de la + dernière ligne d'en-tête la faisait disparaître, après l'avoir + anonymisée et comptée. + """ + source = self._ecrire( + "d.csv", + "Bruant,599\n" + "N° ville,N° magasin\n" + "Aubel,501\n" + "Aubel,502\n" + "Aubel,503\n" + "Aubel,504\n", + ) + sortie = os.path.join(self.base, "d.json") + formats.ecrire( + source, + sortie, + { + "conversion": "json", + "graine": "1", + "entetes_par_feuille": {formats.NOM_FEUILLE_NEUTRE: [2]}, + }, + ) + arbre = json.load(open(sortie, encoding="utf-8")) + (enregistrements,) = arbre.values() + self.assertEqual(len(enregistrements), 5) + + def test_le_nom_du_fichier_source_ne_sort_pas(self): + """Le dialogue promet que le nom du fichier n'est pas anonymisé. + + Le recracher comme clé de premier niveau du JSON injectait dans la + copie un identifiant qui n'était même pas dans la grille. + """ + source = self._ecrire("Client_Tremblay_2024.csv", "nom\nAlpha\n") + sortie = os.path.join(self.base, "o.json") + formats.ecrire(source, sortie, {"conversion": "json", "graine": "1"}) + self.assertNotIn( + "Client_Tremblay", open(sortie, encoding="utf-8").read() + ) + + def test_json_les_cles_sont_de_la_structure_et_sont_dites(self): + source = self._ecrire( + "k.json", '{"Alpha": {"responsable": "Beta", "solde": 1200}}' + ) + apercu = formats.plan(source, {"graine": "3"}) + for exemple in apercu["apercu"]: + self.assertNotEqual(exemple["avant"], "Alpha") + self.assertIn( + "Object keys are kept as structure; they may identify.", + apercu["avertissements"], + ) + + def test_xml_la_queue_est_anonymisee(self): + """Un export d'ERP nommé « .xls » qui est du HTML arrive ici. + + La moitié d'une cellule vit dans la QUEUE d'un élément : « Client + X Nom » porte « Nom » après la balise fermante. + """ + source = self._ecrire( + "h.xml", "Client ABC Alpha" + ) + sortie = os.path.join(self.base, "o.xml") + formats.ecrire(source, sortie, {"graine": "3"}) + rendu = open(sortie, encoding="utf-8").read() + self.assertNotIn("Alpha", rendu) + # L'espace d'encadrement survit, sinon deux mots se collent. + self.assertRegex(rendu, r"\w ") + + def test_la_portee_gouverne_aussi_le_json(self): + """L'écriture passait par un SECOND parcours de l'arbre. + + Il ignorait le plancher et les colonnes intactes, au point de + détruire l'external ID que le plancher venait de protéger. + """ + source = self._ecrire( + "r.json", + json.dumps( + [ + { + "id": 7, + "partner_id/id": "base.p7", + "nom": "Alpha", + "ville": "Beta", + } + ] + ), + ) + sortie = os.path.join(self.base, "o.json") + formats.ecrire( + source, sortie, {"graine": "3", "colonnes_intactes": ["nom"]} + ) + arbre = json.load(open(sortie, encoding="utf-8")) + self.assertEqual(arbre[0]["id"], 7) + self.assertEqual(arbre[0]["partner_id/id"], "base.p7") + self.assertEqual(arbre[0]["nom"], "Alpha") + # Et la colonne qui EST en portée bouge, sinon le test passerait + # sur une recopie verbatim de tout le fichier. + self.assertNotEqual(arbre[0]["ville"], "Beta") + + def test_un_csv_SANS_en_tete_voit_sa_ligne_1_anonymisee(self): + """C'était la fuite : la ligne 1 était PRÉSUMÉE d'en-tête, donc + recopiée en clair, alors qu'elle porte un enregistrement complet. + + La mesure conclut qu'il n'y a pas d'en-tête, et la ligne entre en + portée. Rien n'est alors gardé, donc rien n'est à nommer — c'est + ce que l'ancien avertissement compensait de son mieux. + """ + source = self._ecrire("sans.csv", "aboulie,1200\nacai,830\n") + apercu = formats.plan(source, {"graine": "3"}) + self.assertEqual(apercu["entete_gardee"], []) + avant = [c["avant"] for c in apercu["apercu"]] + self.assertIn("aboulie", avant) + + def test_un_csv_AVEC_en_tete_le_garde_et_le_nomme(self): + """L'autre sens : la mesure ne doit pas anonymiser un vrai + en-tête, qui rendrait la copie illisible. + + La grille porte des types MÊLÉS, comme un export réel : c'est le + régime où la mesure tranche. Une grille étroite et tout- + alphabétique ne se décide pas, et le test suivant le dit. + """ + source = self._ecrire( + "avec.csv", + "etiquette,montant,date,code\n" + "aboulie,1200,2019-01-02,A1\n" + "acai,830,2019-01-03,B2\n" + "adobe,940,2019-01-04,C3\n", + ) + apercu = formats.plan(source, {"graine": "3"}) + valeurs = [c["valeur"] for c in apercu["entete_gardee"]] + self.assertIn("etiquette", valeurs) + + def test_au_dela_du_plafond_ce_qui_n_est_pas_liste_est_COMPTE(self): + """Le plafond borne la liste, jamais le compte. + + Le taire faisait consentir sur un extrait pris pour le tout : + douze cellules montrées d'un empan qui en garde quinze, et les + trois autres partaient en clair sans que rien ne le dise. + """ + largeur = formats.PLAFOND_GARDEES_LISTEES + 3 + source = self._ecrire( + "plafond.csv", + ",".join("Champ%02d" % i for i in range(largeur)) + + "\n" + + "".join( + ",".join(str(100 + rang * largeur + i) for i in range(largeur)) + + "\n" + for rang in range(6) + ), + ) + apercu = formats.plan(source, {"graine": "3"}) + self.assertEqual( + len(apercu["entete_gardee"]), formats.PLAFOND_GARDEES_LISTEES + ) + self.assertEqual( + apercu["entete_gardee_omises"], {formats.NOM_FEUILLE_NEUTRE: 3} + ) + + def test_en_deca_du_plafond_rien_n_est_annonce_comme_omis(self): + source = self._ecrire( + "court.csv", + "etiquette,montant\naboulie,1200\nacai,830\nadobe,940\n", + ) + apercu = formats.plan(source, {"graine": "3"}) + self.assertEqual(apercu["entete_gardee_omises"], {}) + + def test_une_grille_tout_alphabetique_ne_se_decide_pas(self): + """La limite, ÉNONCÉE plutôt que masquée par un seuil ajusté. + + Rien de structurel ne sépare « nom » de « aboulie » : seul un + vocabulaire le ferait, et une liste de noms de champs connus est + une classe ouverte. Le verdict est donc « pas d'en-tête », ce qui + ANONYMISE la ligne — le côté sur lequel pencher — et l'opérateur + corrige. + """ + source = self._ecrire( + "mots.csv", + "nom,ville\naboulie,acai\nacanthe,acai\nadelphique,acai\n", + ) + apercu = formats.plan(source, {"graine": "3"}) + self.assertEqual(apercu["entete_gardee"], []) + + def test_la_table_ne_peut_pas_ecraser_la_source(self): + source = self._ecrire("t.json", '{"client": "Alpha"}') + avant = open(source, encoding="utf-8").read() + with self.assertRaises(formats.ErreurMoteur) as capture: + formats.ecrire( + source, + os.path.join(self.base, "o.json"), + {"table_chemin": source}, + ) + self.assertEqual(capture.exception.cle, "table_source") + self.assertEqual(open(source, encoding="utf-8").read(), avant) + + def test_la_table_ne_peut_pas_remplacer_la_copie(self): + source = self._ecrire("s.csv", "nom\nAlpha\n") + sortie = os.path.join(self.base, "o.csv") + with self.assertRaises(formats.ErreurMoteur) as capture: + formats.ecrire(source, sortie, {"table_chemin": sortie}) + self.assertEqual(capture.exception.cle, "table_source") + self.assertFalse(os.path.exists(sortie)) + + def test_la_table_existante_repasse_en_0600(self): + """`os.open` n'applique son mode QU'À la création. + + Une table arrivée en 0644 par un clone, un `cp` ou un `tar -x` le + resterait — et c'est le cas normal du flux prévu. + """ + source = self._ecrire("s.csv", "nom\nAlpha\n") + table = os.path.join(self.base, "t.json") + with open(table, "w", encoding="utf-8") as fh: + fh.write("{}") + os.chmod(table, 0o644) + formats.ecrire( + source, + os.path.join(self.base, "o.csv"), + {"table_chemin": table}, + ) + self.assertEqual(os.stat(table).st_mode & 0o777, 0o600) + + def test_le_plancher_ne_recopie_plus_les_noms(self): + """La fuite qui a motivé tout ce bloc. + + Cinq colonnes sur six d'un export import-compatible étaient + recopiées mot pour mot, et l'écran l'annonçait comme une + protection. + """ + source = self._ecrire( + "export.csv", + "id,partner_id,user_id,display_name,state,montant\n" + "7,Alpha,Beta,Gamma,Delta,1200.50\n", + ) + sortie = os.path.join(self.base, "o.csv") + formats.ecrire(source, sortie, {"graine": "3"}) + rendu = open(sortie, encoding="utf-8").read() + for valeur in ("Alpha", "Beta", "Gamma", "Delta"): + self.assertNotIn(valeur, rendu, valeur) + # « id » reste, lui : son contenu a la forme d'un identifiant. + self.assertIn("7,", rendu) + + +def _cles_du_menu(traduites_seulement=True): + """Les clés littérales passées à `t()` dans le module du menu.""" + import ast + + chemin = os.path.join( + os.path.dirname(__file__), + "..", + "script", + "todo", + "transform_menu.py", + ) + arbre = ast.parse(open(chemin, encoding="utf-8").read()) + cles = [] + for noeud in ast.walk(arbre): + if ( + isinstance(noeud, ast.Call) + and isinstance(noeud.func, ast.Name) + and noeud.func.id == "t" + and noeud.args + and isinstance(noeud.args[0], ast.Constant) + and isinstance(noeud.args[0].value, str) + ): + valeur = noeud.args[0].value + if valeur in cles: + continue + if traduites_seulement and valeur not in (todo_i18n.TRANSLATIONS): + continue + cles.append(valeur) + return cles + + +class TestConseilDuRefus(unittest.TestCase): + """Un refus dont l'opérateur ne devinerait pas le remède. + + Une macro cite couramment la valeur d'une cellule, et le projet VBA est + recopié tel quel : garder les macros rend alors la copie irrecevable au + filet. Le refus est juste — la valeur sortirait en clair — mais le + détail nomme une partie du format sans dire quoi en faire, alors que + l'option qui le cause vient d'être choisie un écran plus tôt. + """ + + def test_le_conseil_est_une_cle_a_part(self): + """Mêler le remède au détail les rendait intraduisibles tous les + deux : le détail porte un chemin, le conseil une phrase.""" + exc = formats.ErreurMoteur("fuite_detectee", "1 — x", "conseil") + self.assertEqual(exc.detail, "1 — x") + self.assertEqual(exc.conseil, "conseil") + + def test_sans_conseil_l_attribut_existe_quand_meme(self): + """Le CLI le lit sur toute erreur, pas sur celles qui en ont un.""" + self.assertEqual(formats.ErreurMoteur("format_inconnu").conseil, "") + + def test_le_conseil_est_traduit_dans_les_deux_langues(self): + avis = ( + "The kept VBA project quotes a source value;" + " answer no to the macro question to write the copy." + ) + self.assertIn(avis, todo_i18n.TRANSLATIONS) + for langue in ("fr", "en"): + with self.subTest(langue=langue): + self.assertTrue(todo_i18n.TRANSLATIONS[avis][langue].strip()) + + +class TestPolices(unittest.TestCase): + """Aucune forme ne sépare une fonte courante d'une fonte de marque. + + Le renommage porte donc sur toutes celles que la liste ne nomme pas, et + l'erreur penche du côté du dégât cosmétique. La liste est le confort de + la copie, jamais la propriété de sûreté. + """ + + def test_les_courantes_gardent_leur_nom(self): + for nom in ( + "Calibri", + "Liberation Sans", + "DejaVu Sans", + "Consolas", + "Georgia", + "Wingdings", + "Noto Sans", + ): + with self.subTest(nom=nom): + self.assertIn(nom, formats.POLICES_COURANTES) + + def test_une_fonte_inconnue_est_renommee(self): + self.assertNotIn("Aboulie Sans", formats.POLICES_COURANTES) + + +class TestAvertissementEncodage(unittest.TestCase): + """Le délimiteur de la source est repris, son encodage NON. + + Le rapport annonce l'encodage détecté, ce qui laissait croire que la + copie le garde. Elle sort en UTF-8 : un mot du vivier ou un en-tête + gardé peut ne pas s'encoder dans le jeu d'origine, et l'écriture + échouerait APRÈS la question du consentement. + """ + + AVIS = "The copy is written in UTF-8, whatever the source was." + + def test_un_encodage_autre_est_annonce(self): + for encodage in ("cp1252", "latin-1", "ISO-8859-15"): + with self.subTest(encodage=encodage): + dits = formats._avertissements( + {"format": "csv", "encodage": encodage}, {} + ) + self.assertIn(self.AVIS, dits) + + def test_utf8_ne_dit_rien(self): + """Rien ne change, donc rien à dire : un avis sans objet use + l'attention qu'il faudra ailleurs.""" + for encodage in ("utf-8", "UTF_8", "ascii", None, ""): + with self.subTest(encodage=encodage): + dits = formats._avertissements( + {"format": "csv", "encodage": encodage}, {} + ) + self.assertNotIn(self.AVIS, dits) + + +class TestFichiersPrevus(unittest.TestCase): + """Les chemins annoncés doivent être ceux qui seront écrits. + + Ce n'est pas qu'un affichage : cette liste est ce que le contrôle + d'écrasement et celui de la table de correspondance examinent. Prédite + d'après le nom d'ORIGINE d'une feuille, elle annonçait des chemins qui + n'existeraient jamais, montrait le nom du client à l'écran, et laissait + les fichiers réels échapper aux deux contrôles. + """ + + def setUp(self): + self.feuilles = [ + formats.Feuille("Cabinet Lavigne", [["c"], ["x"]]), + formats.Feuille("Fournisseurs 2024", [["c"], ["y"]]), + ] + self.options = { + "vivier": VIVIER, + "feuilles": [], + "destination": "/tmp/sortie", + "conversion": "csv", + } + + def test_les_chemins_portent_le_nom_ANONYMISE(self): + table = noyau.Correspondance() + prevus = formats._fichiers_prevus( + "s.xlsx", self.feuilles, self.options, table + ) + self.assertEqual(len(prevus), 2) + for chemin in prevus: + self.assertNotIn("Lavigne", chemin) + self.assertNotIn("Fournisseurs", chemin) + + def test_la_prediction_fige_ce_que_la_conversion_retrouvera(self): + """La table est une correspondance stable : le nom réservé par la + prédiction est celui que la conversion lira ensuite.""" + table = noyau.Correspondance() + prevus = formats._fichiers_prevus( + "s.xlsx", self.feuilles, self.options, table + ) + attendus = [ + "%s.csv" % formats._nom_de_feuille_anonyme(f, table, self.options) + for f in self.feuilles + ] + self.assertEqual([os.path.basename(c) for c in prevus], attendus) + + def test_sans_table_le_nom_d_origine_reste(self): + """Rien n'est anonymisé quand rien ne l'est : la prédiction ne + doit pas inventer un nom que l'écriture ne produira pas.""" + prevus = formats._fichiers_prevus( + "s.xlsx", self.feuilles, self.options, None + ) + self.assertIn( + "Cabinet_Lavigne.csv", [os.path.basename(c) for c in prevus] + ) + + def test_une_seule_feuille_garde_la_destination_telle_quelle(self): + prevus = formats._fichiers_prevus( + "s.csv", self.feuilles[:1], self.options, noyau.Correspondance() + ) + self.assertEqual(prevus, ["/tmp/sortie"]) + + +class TestClesDistinctesEnJson(unittest.TestCase): + """Un objet JSON écrase la clé qu'il répète, un tableur non. + + L'en-tête d'un tableur n'est qu'une ligne : rien ne l'empêche de + porter deux fois « montant », ni de laisser deux colonnes sans titre. + Rendu tel quel en clés d'objet, cela perdait des colonnes ENTIÈRES + dans la copie, sans qu'une ligne du rapport ne le dise. + """ + + def test_etiquettes_repetees_se_distinguent(self): + self.assertEqual( + formats._cles_distinctes(["montant", "montant", "montant"]), + ["montant", "montant_2", "montant_3"], + ) + + def test_etiquette_vide_n_est_pas_none(self): + """La chaîne vide et l'espace ne passaient pas par le repli.""" + self.assertEqual( + formats._cles_distinctes([None, "", " ", "x"]), + ["c1", "c2", "c3", "x"], + ) + + def test_l_etiquette_garde_ses_espaces(self): + """Le test porte sur l'étiquette dépouillée, la clé la garde + telle quelle : la dépouiller altérerait la copie en silence.""" + self.assertEqual(formats._cles_distinctes([" Nom "]), [" Nom "]) + + def test_le_repli_ne_collisionne_pas_avec_une_etiquette(self): + """Une colonne littéralement intitulée « c2 » existe.""" + self.assertEqual( + formats._cles_distinctes(["c2", None]), ["c2", "c2_2"] + ) + + def test_autant_de_cles_que_de_colonnes(self): + for etiquettes in ( + ["a", "a", None, "", "a"], + [None] * 5, + ["x"], + [], + ): + with self.subTest(etiquettes=etiquettes): + cles = formats._cles_distinctes(etiquettes) + self.assertEqual(len(cles), len(etiquettes)) + self.assertEqual(len(set(cles)), len(etiquettes)) + + +class TestConversion(unittest.TestCase): + """Les cibles de conversion, qu'aucun test n'exerçait. + + C'est ce trou qui a laissé passer, tour à tour : le nom du fichier + source recraché comme clé de premier niveau, une date rendue en texte + ISO dans un classeur, et un nom de balise XML illégal écrit sans + broncher puis annoncé comme écrit. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _csv(self, nom, contenu): + chemin = os.path.join(self.base, nom) + with open(chemin, "w", encoding="utf-8") as fh: + fh.write(contenu) + return chemin + + def test_xml_etiquette_qui_commence_par_un_chiffre(self): + """XML interdit à un nom d'élément de commencer par un chiffre. + + « 2024 » produisait `<2024>`, écrit, annoncé comme écrit, et refusé + par tout analyseur. + """ + import xml.etree.ElementTree as ET + + source = self._csv("mois.csv", "2024,nom\n1200,Alpha\n") + sortie = os.path.join(self.base, "o.xml") + formats.ecrire(source, sortie, {"conversion": "xml", "graine": "3"}) + # La seule assertion qui compte : le fichier se relit. + ET.parse(sortie) + + def test_nom_de_balise_sur(self): + self.assertEqual(formats._nom_de_balise_sur("nom", 1), "nom") + self.assertTrue(formats._nom_de_balise_sur("2024", 1)[0].isalpha()) + self.assertEqual(formats._nom_de_balise_sur("", 3), "c3") + self.assertEqual(formats._nom_de_balise_sur("a b", 1), "a_b") + + def test_un_fichier_par_feuille_au_dela_d_une_seule(self): + self.assertFalse( + formats._un_fichier_par_feuille( + "csv", [formats.Feuille("a", [["x"]])], {"feuilles": None} + ) + ) + self.assertTrue( + formats._un_fichier_par_feuille( + "csv", + [ + formats.Feuille("a", [["x"]]), + formats.Feuille("b", [["y"]]), + ], + {"feuilles": None}, + ) + ) + + def test_repertoire_un_fichier_par_feuille(self): + feuilles = [ + formats.Feuille("Ventes", [["nom"], ["Alpha"]]), + formats.Feuille("Achats", [["nom"], ["Beta"]]), + ] + cible = os.path.join(self.base, "lot") + ecrits = formats._convertir_vers_repertoire(cible, feuilles, {}) + self.assertEqual(len(ecrits), 2) + self.assertEqual(os.stat(cible).st_mode & 0o777, 0o700) + + def test_conversion_vers_xml_refuse_plusieurs_feuilles(self): + feuilles = [ + formats.Feuille("a", [["nom"], ["Alpha"]]), + formats.Feuille("b", [["nom"], ["Beta"]]), + ] + with self.assertRaises(formats.ErreurMoteur): + formats._convertir_vers_xml( + os.path.join(self.base, "o.xml"), feuilles + ) + + def test_le_delimiteur_detecte_survit_a_l_ecriture(self): + """Il servait à LIRE et pas à écrire. + + Un fichier à point-virgule revenait en virgule, et le tableur du + destinataire le rendait en une seule colonne — après que l'écran + avait imprimé « délimiteur détecté : ; ». + """ + source = self._csv("pv.csv", "nom;ville\nAlpha;Beta\n") + sortie = os.path.join(self.base, "o.csv") + formats.ecrire(source, sortie, {"graine": "3"}) + rendu = open(sortie, encoding="utf-8").read() + self.assertIn(";", rendu) + self.assertNotIn(",", rendu) + + def test_une_cible_impossible_refuse_avant_l_APERCU(self): + """Le refus venait du graveur, après le consentement. + + Et sous une clé qui accusait le format au lieu de nommer la + contrainte. `_verifier_conversion` est appelée par `_preparer`, + donc par `plan` comme par `ecrire` : le même refus aux deux. + """ + feuilles = [ + formats.Feuille("Une", [["nom"], ["Alpha"]]), + formats.Feuille("Deux", [["nom"], ["Beta"]]), + ] + with self.assertRaises(formats.ErreurMoteur) as capture: + formats._verifier_conversion( + "xlsx", feuilles, {"conversion": "xml"} + ) + self.assertEqual(capture.exception.cle, "conversion_impossible") + + def test_une_seule_feuille_vers_xml_passe(self): + formats._verifier_conversion( + "xlsx", + [formats.Feuille("Une", [["nom"], ["Alpha"]])], + {"conversion": "xml"}, + ) + + def test_la_selection_compte_dans_le_refus(self): + """Deux feuilles, une seule retenue : la cible redevient possible.""" + feuilles = [ + formats.Feuille("Une", [["nom"], ["Alpha"]]), + formats.Feuille("Deux", [["nom"], ["Beta"]]), + ] + formats._verifier_conversion( + "xlsx", feuilles, {"conversion": "xml", "feuilles": ["Une"]} + ) + + def test_un_conteneur_ne_fait_pas_lever_openpyxl(self): + """L'erreur brute ressortait sous « format non reconnu ».""" + rendu = formats._valeur_pour_xlsx({"nom": "aboulie"}) + self.assertIsInstance(rendu, str) + self.assertIn("aboulie", rendu) + self.assertIsInstance(formats._valeur_pour_xlsx([1, 2]), str) + + def test_valeur_pour_xlsx_garde_les_types(self): + """openpyxl porte nativement datetime, int, float et bool. + + `valeur_hors_tableur` est écrite pour csv, json et xml, trois + formats SANS types : elle rend une date en chaîne ISO, et la copie + portait du texte là où une date était attendue. + """ + quand = datetime.datetime(2024, 3, 1) + self.assertIs(formats._valeur_pour_xlsx(quand), quand) + self.assertIs(formats._valeur_pour_xlsx(True), True) + self.assertEqual(formats._valeur_pour_xlsx(5), 5) + self.assertIsNone(formats._valeur_pour_xlsx(b"\x00")) + + class FausseFormule: + text = "=SUM(A1:A2)" + + self.assertEqual( + formats._valeur_pour_xlsx(FausseFormule()), "=SUM(A1:A2)" + ) + + def test_bornes_ignorent_les_flottants_non_finis(self): + feuille = formats.Feuille( + "f", + [["montant"], [1200.0], [float("nan")], [float("inf")]], + ) + colonne = formats._stats_colonnes(feuille)[0] + self.assertEqual((colonne["min"], colonne["max"]), (1200.0, 1200.0)) + + +class TestGardeApresEcriture(unittest.TestCase): + """Le filet : relire les octets écrits. + + Sa valeur est de ne dépendre d'AUCUNE énumération de vecteurs. Un + endroit du format que personne n'a pensé à nettoyer produit un refus, + là où une liste de parties à vérifier produirait un silence. Ce sont + ces tests qui prouvent qu'il tire ; sans eux il pourrait être neutralisé + sans qu'une ligne ne rougisse. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _table(self, *valeurs): + table = noyau.Correspondance() + for valeur in valeurs: + noyau.nouveau_mot(valeur, table, VIVIER) + return table + + def test_ne_regarde_que_ce_qui_a_ete_remplace(self): + """Ce qui est hors portée reste par DÉCISION, et est annoncé. + + Le mêler ici rendrait la garde bruyante au point d'être + désactivée, ce qui est la seule manière de la rendre inutile. + """ + table = self._table("Alpha") + self.assertEqual(noyau.valeurs_a_verifier(table), {"Alpha"}) + + def test_ignore_les_chaines_trop_courtes(self): + table = self._table("ok", "abc", "Alpha") + self.assertEqual(noyau.valeurs_a_verifier(table), {"Alpha"}) + + def test_detecte_une_valeur_survivante(self): + cible = os.path.join(self.base, "copie.txt") + with open(cible, "w", encoding="utf-8") as fh: + fh.write("rien ici sauf Alpha qui ne devrait pas y etre") + fuites, ecartees = noyau.verifier_copie([cible], self._table("Alpha")) + self.assertIn("Alpha", fuites) + self.assertEqual(ecartees, 0) + + def test_tolere_ce_qui_est_garde_sciemment(self): + cible = os.path.join(self.base, "copie.txt") + with open(cible, "w", encoding="utf-8") as fh: + fh.write("Alpha reste, il est annonce") + fuites, _ = noyau.verifier_copie( + [cible], self._table("Alpha"), gardees=["Alpha"] + ) + self.assertEqual(fuites, {}) + + def test_balaie_chaque_partie_d_un_zip(self): + """Nommer les parties une à une est ce qui a laissé passer, tour à + tour, un cache de graphique, un titre d'axe, un hyperlien de + cellule et le nom d'une colonne de tableau.""" + cible = os.path.join(self.base, "copie.xlsx") + with zipfile.ZipFile(cible, "w") as archive: + archive.writestr("xl/worksheets/sheet1.xml", "propre") + archive.writestr("xl/un/coin/inattendu.xml", "Alpha") + fuites, _ = noyau.verifier_copie([cible], self._table("Alpha")) + self.assertEqual( + fuites["Alpha"], ["copie.xlsx:xl/un/coin/inattendu.xml"] + ) + + def test_la_couverture_ne_suit_pas_l_alphabet(self): + """Le plafond tronquait une liste TRIÉE. + + La couverture était donc un préfixe lexicographique et non un + échantillon : sur plusieurs colonnes de texte, seule la première + était relue, à chaque exécution — une reprise n'y changeait rien, + et l'écran affichait une écriture propre. + """ + table = noyau.Correspondance() + for index in range(20005): + noyau.nouveau_mot(f"aaa-{index:06d}", table, VIVIER) + # Cette valeur trie APRÈS les vingt mille autres. + noyau.nouveau_mot("zzz_survivante", table, VIVIER) + cible = os.path.join(self.base, "copie.txt") + with open(cible, "w", encoding="utf-8") as fh: + fh.write("il reste zzz_survivante dans la copie") + fuites, ecartees = noyau.verifier_copie([cible], table) + self.assertIn("zzz_survivante", fuites) + self.assertEqual(ecartees, 0) + + def test_une_copie_plate_est_fouillee_ENTIERE(self): + """La nature de la partie décide, pas ce que les regex capturent. + + Un seul couple « > … < » — un fragment HTML dans une colonne + gardée suffit — ramenait le balayage à ce qui les sépare, et toute + valeur hors de cet intervalle sortait sans refus. + """ + for nom, contenu in ( + ("plat.csv", "nom,note\naboulie,gras\nzzz_leak,ici\n"), + ("plat.json", '{"a": "x", "b": "zzz_leak"}'), + ("plat.txt", "rien\nzzz_leak\n"), + ): + cible = os.path.join(self.base, nom) + with open(cible, "w", encoding="utf-8") as fh: + fh.write(contenu) + fuites, _ = noyau.verifier_copie([cible], self._table("zzz_leak")) + self.assertIn("zzz_leak", fuites, nom) + + def test_une_partie_binaire_est_fouillee_ENTIERE(self): + """Un projet VBA n'a ni nœud ni attribut.""" + cible = os.path.join(self.base, "avec.xlsm") + with zipfile.ZipFile(cible, "w") as archive: + archive.writestr( + "xl/worksheets/sheet1.xml", "aboulie" + ) + archive.writestr("xl/vbaProject.bin", b"\x00\x01 zzz_leak \x02") + fuites, _ = noyau.verifier_copie([cible], self._table("zzz_leak")) + self.assertEqual(fuites["zzz_leak"], ["avec.xlsm:xl/vbaProject.bin"]) + + def test_une_copie_propre_ne_produit_aucun_refus(self): + cible = os.path.join(self.base, "propre.csv") + with open(cible, "w", encoding="utf-8") as fh: + fh.write("nom\naboulie\nacai\n") + fuites, _ = noyau.verifier_copie([cible], self._table("Alpha", "Beta")) + self.assertEqual(fuites, {}) + + def test_est_xml_tranche_sur_la_nature(self): + self.assertTrue(noyau._est_xml("xl/styles.xml", "nimporte")) + self.assertTrue(noyau._est_xml("_rels/.rels", "x")) + self.assertTrue(noyau._est_xml("sans_extension", "")) + self.assertTrue(noyau._est_xml("bom", "\ufeff")) + self.assertFalse(noyau._est_xml("x.bin", "\x00 pas du xml")) + self.assertFalse( + noyau._est_xml("plat.csv", "nom\nun gras ici\n") + ) + + def test_les_deux_formes_ne_se_comptent_pas_deux_fois(self): + """Mêlées dans un ensemble, une chaîne portant « & » pesait deux. + + Le total dépassait alors la tolérance annoncée, et le filet + refusait du travail légitime. + """ + ecrites, nues, _t_e, _t_n = noyau._chaines_distinctes( + "Roy & Filssimple" + ) + self.assertIn("Roy & Fils", ecrites) + self.assertIn("Roy & Fils", nues) + # Une valeur gardée qui porte « & » n'est pas refusée. + cible = os.path.join(self.base, "amp.xml") + with open(cible, "w", encoding="utf-8") as fh: + fh.write("Roy & Fils") + fuites, _ = noyau.verifier_copie( + [cible], + self._table("Roy & Fils"), + gardees=["Roy & Fils"], + ) + self.assertEqual(fuites, {}) + + def test_la_tolerance_ne_couvre_que_l_EGALITE(self): + """L'appartenance à un bloc joint est un test de sous-chaîne. + + Toute chaîne tolérée qui CONTIENT la valeur la tolérait, y compris + là où la valeur fuit — le grain le plus large possible. + """ + cible = os.path.join(self.base, "c.csv") + with open(cible, "w", encoding="utf-8") as fh: + fh.write("nom\naboulie\nRoy et Fils SA\nRoy et Fils\n") + fuites, _ = noyau.verifier_copie( + [cible], + self._table("Roy et Fils"), + gardees=["Roy et Fils SA"], + ) + self.assertIn("Roy et Fils", fuites) + + def test_une_valeur_toleree_a_l_identique_peut_paraitre_deux_fois(self): + """Le cas ordinaire d'une colonne laissée intacte.""" + cible = os.path.join(self.base, "d.csv") + with open(cible, "w", encoding="utf-8") as fh: + fh.write("ville\nSainte-Lambda\nSainte-Lambda\n") + fuites, _ = noyau.verifier_copie( + [cible], + self._table("Sainte-Lambda"), + gardees=["Sainte-Lambda"], + ) + self.assertEqual(fuites, {}) + + def test_le_filet_voit_ce_que_le_graveur_a_ECHAPPE(self): + """`csv` double le guillemet, `json.dump` le préfixe. + + Chercher les octets bruts d'un nom portant un guillemet n'y + trouvait alors rien, et la copie partait avec. + """ + for nom, contenu in ( + ("e.csv", 'nom\naboulie\n"Roy ""et"" Fils"\n'), + ("f.json", json.dumps({"a": 'Roy "et" Fils'})), + ): + cible = os.path.join(self.base, nom) + with open(cible, "w", encoding="utf-8") as fh: + fh.write(contenu) + fuites, _ = noyau.verifier_copie( + [cible], self._table('Roy "et" Fils') + ) + self.assertIn('Roy "et" Fils', fuites, nom) + + def test_le_prefiltre_ne_rend_aucun_faux_negatif(self): + """La propriété sur laquelle tout le balayage repose. + + Le préfiltre n'existe que pour écarter une valeur sans la + chercher : s'il pouvait écarter une valeur PRÉSENTE, le filet + deviendrait aveugle en silence, ce qui est exactement le mode de + défaillance qu'il est là pour empêcher. + """ + bloc = noyau._joindre( + {f"chaine_{i}_avec_du_texte" for i in range(500)} + ) + bits = noyau._prefiltre(bloc) + for i in range(500): + for valeur in ( + f"chaine_{i}_avec_du_texte", + f"aine_{i}_avec", + "avec_du_texte", + ): + self.assertTrue( + noyau._peut_contenir(bits, valeur), + f"faux négatif sur {valeur!r}", + ) + + def test_le_prefiltre_ecarte_vraiment(self): + bloc = noyau._joindre({"aboulie", "acai", "acanthe"}) + bits = noyau._prefiltre(bloc) + ecartees = sum( + 0 if noyau._peut_contenir(bits, f"valeur_{i}_absente") else 1 + for i in range(200) + ) + self.assertGreater(ecartees, 150) + + def test_une_valeur_plus_courte_que_le_ngramme_passe_toujours(self): + bits = noyau._prefiltre(noyau._joindre({"aboulie"})) + self.assertTrue(noyau._peut_contenir(bits, "ab")) + + def test_le_prefiltre_ne_change_pas_le_verdict(self): + """Au-dessus et en dessous du seuil, le même résultat. + + C'est la seule façon de garder le préfiltre honnête : il accélère, + il ne décide pas. + """ + cible = os.path.join(self.base, "copie.txt") + with open(cible, "w", encoding="utf-8") as fh: + fh.write("il reste zzz_survivante ici, et rien d'autre") + table = noyau.Correspondance() + for i in range(noyau.SEUIL_PREFILTRE + 50): + noyau.nouveau_mot(f"absente_{i:05d}", table, VIVIER) + noyau.nouveau_mot("zzz_survivante", table, VIVIER) + avec, _ = noyau.verifier_copie([cible], table) + petite = noyau.Correspondance() + noyau.nouveau_mot("zzz_survivante", petite, VIVIER) + sans, _ = noyau.verifier_copie([cible], petite) + self.assertIn("zzz_survivante", avec) + self.assertEqual(set(avec), set(sans)) + + def test_l_octet_nul_empeche_une_valeur_a_cheval(self): + """Sans séparateur, deux chaînes voisines en fabriqueraient une.""" + bloc = noyau._joindre({"aaabbb", "cccddd"}) + self.assertEqual(bloc.count("bbbccc"), 0) + self.assertIn("\x00", bloc) + + def test_l_ecriture_refuse_et_n_laisse_aucun_fichier(self): + """Le refus doit être total : une copie partielle serait livrée.""" + source = os.path.join(self.base, "s.csv") + with open(source, "w", encoding="utf-8") as fh: + fh.write("nom\nAlpha\n") + sortie = os.path.join(self.base, "o.csv") + + # Un graveur qui recopie la source telle quelle : exactement le + # mutant que la suite laissait passer avant cette garde. + def graveur_inerte(destination, feuille, options): + with open(destination, "w", encoding="utf-8") as fh: + fh.write("nom\nAlpha\n") + return [destination] + + vrai = formats._ecrire_csv + formats._ecrire_csv = graveur_inerte + try: + with self.assertRaises(formats.ErreurMoteur) as capture: + formats.ecrire(source, sortie, {"graine": "3"}) + finally: + formats._ecrire_csv = vrai + self.assertEqual(capture.exception.cle, "fuite_detectee") + self.assertFalse(os.path.exists(sortie)) + + +class TestI18n(unittest.TestCase): + """Aucune clé ne doit s'afficher en anglais faute de traduction.""" + + def test_chaque_cle_erreur_du_moteur_est_traduite(self): + """Le garde AST ne peut pas couvrir le moteur. + + Les clés du moteur sont des constantes dans un dict JSON, jamais + passées à `t()` chez lui — c'est le menu qui traduit. Et `t()` + rend la clé quand elle manque, sans lever : sans cette + vérification, un francophone lirait de l'anglais et aucun test ne + tomberait. + """ + manquantes = [ + valeur + for valeur in noyau.ERREURS.values() + if valeur not in todo_i18n.TRANSLATIONS + ] + self.assertEqual(manquantes, []) + + def test_chaque_cle_du_menu_est_traduite(self): + manquantes = [ + cle + for cle in _cles_du_menu(traduites_seulement=False) + if cle not in todo_i18n.TRANSLATIONS + ] + self.assertEqual(manquantes, []) + + def test_les_deux_langues_sont_remplies(self): + for cle in list(noyau.ERREURS.values()): + entree = todo_i18n.TRANSLATIONS[cle] + self.assertTrue(entree.get("fr"), cle) + self.assertTrue(entree.get("en"), cle) + + def test_les_libelles_correspondent_a_leur_parseur(self): + """Un « (O/n) » lu par _is_yes promet oui et vaut non. + + La convention du dépôt : `(Y/n)` / `(O/n)` se lit par + `_is_yes_default_yes`, `(y/N)` / `(o/N)` par `_is_yes`. + """ + # SEULEMENT les clés de ce module : le dépôt en compte des + # milliers, et d'autres menus formulent légitimement autrement. + verifiees = 0 + for cle in _cles_du_menu(): + entree = todo_i18n.TRANSLATIONS[cle] + if "(Y/n)" in cle: + self.assertIn("(O/n)", entree["fr"], cle) + verifiees += 1 + if "(y/N)" in cle: + self.assertIn("(o/N)", entree["fr"], cle) + verifiees += 1 + self.assertGreater(verifiees, 4, "le test ne vérifie rien") + + +class TestEnvironnement(unittest.TestCase): + def test_formats_stdlib_disponibles_sans_venv(self): + for fmt in ("csv", "json", "xml", "macros"): + self.assertTrue(transform_setup.available(fmt), fmt) + + def test_interpreteur_toujours_executable(self): + for fmt in ("csv", "xlsx", None): + self.assertTrue( + os.path.isfile(transform_setup.engine_python(fmt)), fmt + ) + + def test_paquet_systeme_delegue_a_todo_install(self): + """Aucune cascade apt/dnf/pacman/zypper de plus ici. + + Et pas d'entrée `pacman` : mdbtools n'est pas dans les dépôts + officiels d'Arch, seulement l'AUR. `install_command` doit alors + rendre None plutôt qu'une commande qui échoue APRÈS le mot de + passe sudo. + """ + self.assertNotIn("pacman", transform_setup.PAQUETS_ACCESS) + commande = transform_setup.system_packages_cmd() + self.assertTrue(commande is None or "mdbtools" in commande) + + def test_creation_refusee_n_installe_rien(self): + lances = [] + fait = transform_setup.create( + ask=lambda _: "n", executeur=lances.append + ) + self.assertFalse(fait) + self.assertEqual(lances, []) + + def test_capabilities_nomme_xlsb_illisible(self): + self.assertFalse(transform_setup.capabilities()["xlsb"]) + + +# ---------------------------------------------------------------------- +# Ce qui exige openpyxl. Ignoré et DIT quand il manque. +# ---------------------------------------------------------------------- +MARQUEURS = { + "props_creator": "ZQXCREATOR", + "props_modif": "ZQXMODIF", + "props_title": "ZQXTITLE", + "props_keywords": "ZQXKEYWORD", + "custom_prop": "ZQXCUSTOM", + "comment_text": "ZQXCOMTEXT", + "comment_author": "ZQXCOMAUTH", + "hyperlink": "ZQXHYPER", + "header": "ZQXHEADER", + "footer": "ZQXFOOTER", + "validation": "ZQXVALID", + "condformat": "ZQXCONDF", + "chart_title": "ZQXCHTITLE", + "axis_x_title": "ZQXAXISX", + "axis_y_title": "ZQXAXISY", + "series_name": "ZQXSERIES", + "cat_cache": "ZQXCATCACHE", + "cell_value": "ZQXCELL", + "defined_value": "ZQXNAMEVAL", + # Les trois vecteurs qui portent une COPIE ENTIÈRE de la source. La + # fixture ne les portait pas, si bien que les assertions d'absence + # portaient sur des parties JAMAIS présentes : `ws._pivots = []`, + # `ws._images = []` et `keep_links=False` pouvaient chacun disparaître + # sans qu'une ligne ne rougisse. + "pivot_cache": "ZQXPIVOT", + "lien_externe": "ZQXEXTLINK", + "image": "ZQXIMAGE", + "filtre": "ZQXFILTRE", + # Une feuille GRAPHIQUE n'a aucune cellule : la passe sur la grille ne + # la voit pas, `worksheets` l'exclut par construction, et son titre + # comme son en-tête ne passent par aucune règle. + "titre_feuille_graph": "ZQXCHSHEET", + "entete_feuille_graph": "ZQXCHHEAD", + # Le littéral d'un format de nombre personnalisé et le nom d'un style + # nommé : tous deux vivent dans xl/styles.xml, hors de toute cellule. + "format_nombre": "ZQXNUMFMT", + "style_nomme": "ZQXSTYLE", + # Un axe et une étiquette de données portent leur PROPRE format, dans + # la partie graphique. Vider les titres et les caches les laisse, et + # le filet ne les rattrape pas : il ne refuse que ce qui a été annoncé + # remplacé, et un libellé qui n'a jamais été lu d'une cellule n'est + # annoncé par personne. + "format_axe": "ZQXAXISFMT", + "format_etiquette": "ZQXLBLFMT", + # Un style différentiel porte sa police EN LIGNE : elle n'est pas dans + # la liste des polices du classeur, que la passe de renommage + # parcourt. Une fonte de marque nommée par une seule mise en forme + # conditionnelle traversait. + "police_dxf": "ZQXFONTDXF", +} + +# Les quatre familles référencées par une formule. On ne peut pas les +# supprimer sans casser ce que la règle de la formule vient de préserver : +# elles sont RAPPORTÉES, pas effacées. +MARQUEURS_CLASSE_B = { + "defined_global": "ZQXNAMEGLOB", + "defined_local": "ZQXNAMELOC", + "table_name": "ZQXTABLE", + "formula_literal": "ZQXFORMULA", + "sheet_name": "ZQXSHEET", +} + +TOUS_MARQUEURS = dict(MARQUEURS) +TOUS_MARQUEURS.update(MARQUEURS_CLASSE_B) + + +def _fabriquer_fixture(chemin): + """Un classeur portant un marqueur inventé dans chaque vecteur. + + Le graphique DOIT être bâti par `add_data()` + `set_categories()` : un + `Series()` construit à la main puis appendu n'écrit ni ``, ni + ``, ni `strRef` : les balises de `` se limitent alors à + `idx`, `order`, `tx`, `spPr`. Une fixture bâtie ainsi ne porte pas le + vecteur de cache, et le test rapporterait « effacé » sur un marqueur + qui n'a jamais été écrit. + """ + from openpyxl import Workbook + from openpyxl.chart import BarChart, Reference, Series + from openpyxl.chart.label import DataLabelList + from openpyxl.comments import Comment + from openpyxl.formatting.rule import CellIsRule + from openpyxl.packaging.custom import ( + CustomPropertyList, + StringProperty, + ) + from openpyxl.styles import Font, PatternFill + from openpyxl.workbook.defined_name import DefinedName + from openpyxl.worksheet.datavalidation import DataValidation + from openpyxl.worksheet.table import Table, TableStyleInfo + + M = TOUS_MARQUEURS + classeur = Workbook() + onglet = classeur.active + onglet.title = M["sheet_name"] + + onglet["A1"] = "etiquette" + onglet["B1"] = "montant" + onglet["A2"] = M["cell_value"] + onglet["B2"] = 4711 + onglet["A3"] = "autre" + onglet["B3"] = 12 + onglet["C2"] = f'=IF(A2="{M["formula_literal"]}",1,0)' + + classeur.properties.creator = M["props_creator"] + classeur.properties.lastModifiedBy = M["props_modif"] + classeur.properties.title = M["props_title"] + classeur.properties.keywords = M["props_keywords"] + + proprietes = CustomPropertyList() + proprietes.append(StringProperty(name="client", value=M["custom_prop"])) + classeur.custom_doc_props = proprietes + + onglet["A2"].comment = Comment(M["comment_text"], M["comment_author"]) + onglet["A3"].hyperlink = f"https://{M['hyperlink']}.example/rapport.xlsx" + onglet.oddHeader.center.text = M["header"] + onglet.oddFooter.left.text = M["footer"] + + validation = DataValidation( + type="list", formula1=f'"{M["validation"]},autre"' + ) + onglet.add_data_validation(validation) + validation.add("D2:D10") + + onglet.conditional_formatting.add( + "E2:E10", + CellIsRule( + operator="equal", + formula=[f'"{M["condformat"]}"'], + fill=PatternFill(start_color="FFEE1111", end_color="FFEE1111"), + font=Font(name=M["police_dxf"]), + ), + ) + onglet.auto_filter.ref = "A1:B3" + + classeur.defined_names.add( + DefinedName(M["defined_global"], attr_text=f'"{M["defined_value"]}"') + ) + onglet.defined_names.add( + DefinedName(M["defined_local"], attr_text=f"'{onglet.title}'!$A$1") + ) + + onglet["G1"] = M["table_name"] + onglet["G2"] = "x" + tableau = Table(displayName=M["table_name"], ref="G1:G2") + tableau.tableStyleInfo = TableStyleInfo(name="TableStyleMedium9") + onglet.add_table(tableau) + + graphique = BarChart() + graphique.title = M["chart_title"] + graphique.x_axis.title = M["axis_x_title"] + graphique.y_axis.title = M["axis_y_title"] + graphique.add_data( + Reference(onglet, min_col=2, min_row=1, max_row=3), + titles_from_data=True, + ) + graphique.set_categories( + Reference(onglet, min_col=1, min_row=2, max_row=3) + ) + graphique.series.append( + Series( + Reference(onglet, min_col=2, min_row=2, max_row=3), + title=M["series_name"], + ) + ) + graphique.y_axis.numFmt = '#,##0" %s"' % M["format_axe"] + if graphique.dLbls is None: + graphique.dLbls = DataLabelList() + graphique.dLbls.numFmt = '#,##0" %s"' % M["format_etiquette"] + onglet.add_chart(graphique, "J2") + + # Un filtre automatique AVEC une valeur : `auto_filter.ref` seul ne + # pose aucun `filterColumn`, donc l'effacement n'était pas exercé. + onglet.auto_filter.add_filter_column( + 0, [M["filtre"], "autre"], blank=False + ) + + # Une image dont le marqueur vit dans un chunk PNG tEXt : openpyxl ne + # recopie xl/media/ que si Pillow est là, et c'est le vecteur le plus + # dense qu'un classeur puisse porter. + from openpyxl.drawing.image import Image as XLImage + from PIL import Image as PILImage + from PIL import PngImagePlugin + + png = os.path.join(os.path.dirname(chemin), "img.png") + info = PngImagePlugin.PngInfo() + info.add_text("Comment", M["image"]) + PILImage.new("RGB", (4, 4), (200, 10, 10)).save(png, pnginfo=info) + onglet.add_image(XLImage(png), "L2") + + onglet["B2"].number_format = '#,##0" %s"' % M["format_nombre"] + onglet["B3"].number_format = '#,##0" kg"' + from openpyxl.styles import Font, NamedStyle + + style = NamedStyle(name=M["style_nomme"], font=Font(bold=True)) + classeur.add_named_style(style) + onglet["A3"].style = M["style_nomme"] + + feuille_graph = classeur.create_chartsheet(M["titre_feuille_graph"]) + feuille_graph.oddHeader.center.text = M["entete_feuille_graph"] + # Un graphique attaché, comme Excel en produit toujours : openpyxl + # 3.1.2 ne RELIT pas une feuille graphique qui n'en a pas — son + # lecteur de relations lève `AttributeError`. Une fixture sans + # graphique éprouverait ce défaut de la bibliothèque, pas le nôtre. + graphique_feuille = BarChart() + graphique_feuille.add_data( + Reference(onglet, min_col=2, min_row=1, max_row=3), + titles_from_data=True, + ) + feuille_graph.add_chart(graphique_feuille) + + classeur.save(chemin) + _injecter_cache(chemin) + _injecter_parties_de_copie(chemin) + return chemin + + +def _injecter_parties_de_copie(chemin): + """Poser un cache de tableau croisé et un lien externe. + + openpyxl sait les LIRE et non les écrire : sans injection au niveau du + zip, la fixture ne porte pas les deux parties dont le message de + commit dit qu'elles contiennent « une copie entière » de la source. + """ + temporaire = chemin + ".tmp" + parties = { + "xl/pivotCache/pivotCacheRecords1.xml": ( + '' + f"" + "" + ), + "xl/externalLinks/externalLink1.xml": ( + '' + f"" + "" + ), + } + with zipfile.ZipFile(chemin) as entree, zipfile.ZipFile( + temporaire, "w", zipfile.ZIP_DEFLATED + ) as sortie: + for item in entree.infolist(): + # Les membres NON XML passent en octets : décoder + # xl/media/image1.png lèverait UnicodeDecodeError. + sortie.writestr(item, entree.read(item.filename)) + for nom, contenu in parties.items(): + sortie.writestr(nom, contenu) + os.replace(temporaire, chemin) + + +def _injecter_cache(chemin): + """Poser un cache de catégories, qu'openpyxl n'écrit pas lui-même. + + C'est Excel qui remplit `strCache`, et les balises du graphique sont + écrites SANS préfixe « c: » — mesuré. Viser `` ne trouverait + rien, et le vecteur resterait absent de la fixture. + """ + temporaire = chemin + ".tmp" + cache = ( + "ref" + '' + + TOUS_MARQUEURS["cat_cache"] + + "" + ) + injecte = False + with zipfile.ZipFile(chemin) as entree, zipfile.ZipFile( + temporaire, "w", zipfile.ZIP_DEFLATED + ) as sortie: + for item in entree.infolist(): + octets = entree.read(item.filename) + # Le classeur porte plusieurs graphiques : viser le PREMIER qui + # a des catégories. Exiger « » dans chacun ferait tomber + # la fabrication sur le graphique de la feuille graphique, qui + # n'en a pas. + if ( + not injecte + and item.filename.startswith("xl/charts/chart") + and b"" in octets + ): + texte = octets.decode("utf-8") + debut = texte.index("") + fin = texte.index("") + len("") + octets = (texte[:debut] + cache + texte[fin:]).encode("utf-8") + injecte = True + sortie.writestr(item, octets) + # Sans cache injecté, la mesure serait creuse : le test rapporterait + # « effacé » sur un marqueur jamais écrit. + assert injecte, "aucun graphique ne porte " + os.replace(temporaire, chemin) + + +# Le processus de test tourne sous `.venv.erplibre`, qui n'a PAS openpyxl : +# c'est la contrainte de `run_unit_test.sh`. La fabrication de la fixture et +# l'écriture passent donc par le venv dédié, en SOUS-PROCESSUS — ce qui +# éprouve du même coup le protocole JSON du moteur. Le balayage, lui, reste +# ici : il ne demande que `zipfile`. +_AMORCE = ( + "import importlib.util, sys;" + "s = importlib.util.spec_from_file_location('fx', sys.argv[1]);" + "m = importlib.util.module_from_spec(s);" + "s.loader.exec_module(m);" + "m._fabriquer_fixture(sys.argv[2])" +) + + +def _racine(): + return os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) + + +def _sous_processus(arguments): + """Lancer sous l'interpréteur du venv dédié. Rend le CompletedProcess.""" + environnement = dict(os.environ) + environnement["PYTHONPATH"] = _racine() + return subprocess.run( + [transform_setup.engine_python("xlsx")] + list(arguments), + capture_output=True, + text=True, + cwd=_racine(), + env=environnement, + ) + + +def _fabriquer_par_sous_processus(chemin): + acheve = _sous_processus( + ["-c", _AMORCE, os.path.abspath(__file__), chemin] + ) + if acheve.returncode: + raise AssertionError( + "la fixture n'a pas pu être fabriquée :\n" + acheve.stderr + ) + return chemin + + +def _ecrire_par_sous_processus(source, destination, options): + acheve = _sous_processus( + [ + os.path.join("script", "data", "external_file.py"), + "--apply", + source, + "--out", + destination, + "--options", + json.dumps(options), + ] + ) + try: + resultat = json.loads(acheve.stdout or "") + except ValueError: + raise AssertionError( + "stdout ne porte pas de JSON :\n" + + (acheve.stdout or "")[:400] + + "\n" + + acheve.stderr[-800:] + ) + if "erreur" in resultat: + raise AssertionError(f"{resultat['erreur']} {resultat.get('detail')}") + return resultat + + +def _balayer(chemin): + """{clé de marqueur: [parties du zip]} — TOUTES les parties.""" + trouves = {} + with zipfile.ZipFile(chemin) as archive: + for nom in archive.namelist(): + texte = archive.read(nom).decode("utf-8", "ignore") + for cle, marqueur in TOUS_MARQUEURS.items(): + if marqueur in texte: + trouves.setdefault(cle, []).append(nom) + return trouves + + +@unittest.skipUnless( + transform_setup.available("xlsx"), + "openpyxl absent : bâtir .venv.todo.external_data" + " (TODO › Transform data › Install the reading environment)", +) +class TestFuiteXlsx(unittest.TestCase): + """Le test qui garde toute la fonctionnalité. + + Son résultat est MESURÉ, pas espéré. La version précédente de ce + nettoyage était annoncée « vérifiée » et laissait passer trois + vecteurs sur quatre : les caches de graphique, les titres d'axes et + les hyperliens de cellule. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + self.source = _fabriquer_par_sous_processus( + os.path.join(self.base, "source.xlsx") + ) + + def test_la_fixture_porte_bien_tous_les_marqueurs(self): + """Sans cette garde, un « effacé » peut ne rien prouver.""" + presents = set(_balayer(self.source)) + manquants = sorted(set(TOUS_MARQUEURS) - presents) + self.assertEqual(manquants, []) + + def _anonymiser(self, **options): + sortie = os.path.join(self.base, "sortie.xlsx") + _ecrire_par_sous_processus( + self.source, sortie, {"graine": "11", **options} + ) + return sortie + + def test_les_seuls_survivants_sont_les_quatre_familles(self): + survivants = set(_balayer(self._anonymiser())) + self.assertEqual( + survivants, + set(MARQUEURS_CLASSE_B), + "la liste EXACTE : un vecteur rouvert doit faire tomber ce" + " test, pas passer inaperçu", + ) + + def test_le_compte_des_effaces(self): + efface = set(TOUS_MARQUEURS) - set(_balayer(self._anonymiser())) + self.assertEqual(len(TOUS_MARQUEURS), 35) + self.assertEqual(len(efface), 30) + + def test_la_constante_d_une_plage_nommee_passe_par_la_table(self): + """Le NOM survit par nécessité, la VALEUR doit partir. + + Mesurée comme survivante tant qu'on ne remplace pas la constante : + c'est ce que la classe (b) exige, et le nom reste résolvable. + """ + survivants = _balayer(self._anonymiser()) + self.assertNotIn("defined_value", survivants) + self.assertIn("defined_global", survivants) + + def test_les_proprietes_du_document_sont_videes(self): + sortie = self._anonymiser() + with zipfile.ZipFile(sortie) as archive: + coeur = archive.read("docProps/core.xml").decode("utf-8") + # `creator` vaut « openpyxl » par DÉFAUT : sans creator=None, + # l'élément ne disparaît pas, il est rempli. + self.assertNotIn("dc:creator", coeur) + self.assertNotIn("cp:lastModifiedBy", coeur) + + def test_les_parties_qui_portent_une_copie_disparaissent(self): + with zipfile.ZipFile(self._anonymiser()) as archive: + noms = archive.namelist() + for prefixe in ( + "xl/pivotCache", + "xl/externalLinks", + "xl/comments/", + "xl/media/", + ): + self.assertFalse( + [n for n in noms if n.startswith(prefixe)], prefixe + ) + + def test_la_feuille_graphique_part_toujours(self): + """Rien ne peut l'anonymiser : elle n'a pas de cellule. + + La retirer seulement quand une sélection de feuilles existe faisait + mentir l'avertissement dans tous les autres cas. + """ + survivants = _balayer(self._anonymiser()) + self.assertNotIn("titre_feuille_graph", survivants) + self.assertNotIn("entete_feuille_graph", survivants) + + def test_les_graphiques_partent_par_defaut(self): + with zipfile.ZipFile(self._anonymiser()) as archive: + noms = archive.namelist() + self.assertFalse([n for n in noms if n.startswith("xl/charts")]) + + def test_graphiques_gardes_le_nom_de_serie_part_quand_meme(self): + """`s.tx = None` est indispensable : le nom a DEUX formes. + + `littéral` quand il est tapé, + `réf` quand il vient des données. Ni l'une ni + l'autre n'est un cache : vider strCache/numCache les laisserait + toutes deux en place. + """ + sortie = self._anonymiser(garder_graphiques=True) + survivants = _balayer(sortie) + self.assertNotIn("series_name", survivants) + self.assertNotIn("cat_cache", survivants) + self.assertNotIn("chart_title", survivants) + self.assertNotIn("axis_x_title", survivants) + + def test_graphiques_gardes_le_nom_de_feuille_fuit_en_plus(self): + """La référence d'une série porte 'feuille'!$B$1. + + Le chemin par défaut l'évite : c'est une raison de plus d'en + faire le défaut. + """ + sortie = self._anonymiser(garder_graphiques=True) + parties = _balayer(sortie).get("sheet_name", []) + self.assertTrue([p for p in parties if p.startswith("xl/charts")]) + + def test_les_formules_survivent_et_les_valeurs_changent(self): + """Lu par zipfile : le processus de test n'a pas openpyxl.""" + with zipfile.ZipFile(self._anonymiser()) as archive: + feuille = archive.read("xl/worksheets/sheet1.xml").decode("utf-8") + # La formule est conservée telle quelle par la règle. + self.assertIn(TOUS_MARQUEURS["formula_literal"], feuille) + # La valeur de cellule, elle, a changé — les chaînes vivent dans + # sharedStrings.xml, qui est balayé par _balayer(). + self.assertNotIn("cell_value", _balayer(self._anonymiser())) + + def test_le_littéral_d_un_format_de_nombre_est_assaini(self): + """Excel laisse suffixer un nombre d'un libellé. + + Ce libellé vit dans `xl/styles.xml`, hors de toute cellule : aucune + règle ne le voyait, et le filet refusait le classeur sans jamais + l'assainir. Un littéral COURT — une devise, une unité — reste : il + ne porte aucune donnée et le remplacer abîmerait le classeur. + """ + sortie = self._anonymiser() + with zipfile.ZipFile(sortie) as archive: + styles = archive.read("xl/styles.xml").decode("utf-8") + self.assertNotIn(TOUS_MARQUEURS["format_nombre"], styles) + self.assertNotIn(TOUS_MARQUEURS["style_nomme"], styles) + # L'unité survit, et la structure du format avec elle. + self.assertIn("kg", styles) + self.assertIn("#,##0", styles) + + def test_la_source_n_est_pas_modifiee(self): + avant = ( + os.path.getmtime(self.source), + os.path.getsize(self.source), + ) + self._anonymiser() + self.assertEqual( + avant, + ( + os.path.getmtime(self.source), + os.path.getsize(self.source), + ), + ) + + +class TestHorodatageDeLaCopie(unittest.TestCase): + """La copie ne porte pas l'instant où elle a été tirée. + + `dcterms:created` dit la séance de l'opérateur, que rien n'oblige à + transmettre. Il rend surtout le refus du filet ALÉATOIRE : une + cellule qui porte le mois courant — un libellé de période, ordinaire + dans un export comptable — se retrouve dans l'horodatage, et le + compte en surplus du socle bascule d'une exécution à l'autre selon + que `created` et `modified` tombent sur la même seconde. Une entrée + identique est alors refusée une fois sur deux ou trois. + """ + + def setUp(self): + self.base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.base, True) + + def _source(self): + """Un classeur dont une cellule porte le mois COURANT. + + Calculé et non écrit en dur : un littéral rendrait le test caduc + au mois suivant, et c'est justement la coïncidence avec l'instant + de l'écriture qui est éprouvée. + """ + from openpyxl import Workbook + + chemin = os.path.join(self.base, "periode.xlsx") + classeur = Workbook() + onglet = classeur.active + onglet.append(["periode", "montant"]) + mois = datetime.datetime.now().strftime("%Y-%m") + for rang in range(4): + onglet.append([mois, 100 + rang]) + classeur.save(chemin) + return chemin, mois + + def test_le_mois_courant_dans_une_cellule_n_empeche_pas_l_ecriture(self): + """Trois passes : sans le gel, une entrée identique est + refusée une fois sur deux.""" + source, _mois = self._source() + for passe in range(3): + with self.subTest(passe=passe): + sortie = os.path.join(self.base, "c%d.xlsx" % passe) + _ecrire_par_sous_processus(source, sortie, {"graine": "5"}) + self.assertTrue(os.path.isfile(sortie)) + + def test_la_copie_porte_une_date_de_creation_FIGEE(self): + source, _mois = self._source() + sortie = os.path.join(self.base, "c.xlsx") + _ecrire_par_sous_processus(source, sortie, {"graine": "5"}) + with zipfile.ZipFile(sortie) as archive: + core = archive.read("docProps/core.xml").decode("utf-8") + self.assertIn(formats.EPOQUE_FIGEE.strftime("%Y-%m-%d"), core) + + def test_le_createur_ne_dit_pas_openpyxl(self): + """Sans `creator=None`, l'élément n'est pas retiré mais REMPLI.""" + source, _mois = self._source() + sortie = os.path.join(self.base, "c.xlsx") + _ecrire_par_sous_processus(source, sortie, {"graine": "5"}) + with zipfile.ZipFile(sortie) as archive: + core = archive.read("docProps/core.xml").decode("utf-8") + self.assertNotIn("openpyxl", core) + + +class TestLaBandeDuCalibreNeSElargitPas(unittest.TestCase): + """Garder la largeur : le vivier sature avant la bande. + + Sans remise, neuf valeurs à un chiffre se disputent les neuf places + 1..9 et l'identité est interdite : l'attribution bute. Hors calibre, + trois recours élargissent alors la plage — d'un pas, par paliers de + dix, puis en rendant « valeur + 1 » — et chacun rend un nombre d'une + AUTRE largeur, la seule chose que l'option promette. Sous calibre les + trois cèdent la place à un doublon dans la bande. + + Aucun avertissement ne couvre ce cas : les colonnes saturées se + mesurent sur l'étendue de la COLONNE, pas sur la bande du calibre. + """ + + VALEURS = list(range(1, 10)) + [500000] + BORNES = (1, 500000, True) + + def _largeur(self, n): + return len(str(abs(int(n)))) + + def _lot(self, graine, valeurs=None, bornes=None, calibre=True): + rng = random.Random(graine) + table = noyau.Correspondance() + return { + v: noyau.nouveau_nombre( + v, + rng, + bornes=bornes or self.BORNES, + table=table, + calibre=calibre, + ) + for v in (valeurs or self.VALEURS) + } + + def test_aucune_graine_ne_change_une_largeur(self): + """Sans la garde, 24 graines sur 300 en changent une.""" + fautifs = [] + for graine in range(300): + for source, sortie in self._lot(graine).items(): + if self._largeur(sortie) != self._largeur(source): + fautifs.append((graine, source, sortie)) + self.assertEqual([], fautifs[:5]) + + def test_la_bande_saturee_rend_un_DOUBLON_et_non_une_largeur(self): + """Un doublon ne fait rien fuir ; une largeur changée se voit.""" + sorties = self._lot(1) + chiffres = [s for v, s in sorties.items() if v < 10] + self.assertTrue(all(1 <= s <= 9 for s in chiffres), chiffres) + self.assertLess(len(set(chiffres)), len(chiffres)) + + def test_jamais_l_identite_meme_saturee(self): + """La rendre laisserait l'original dans la copie en le comptant + comme remplacé — une fuite annoncée propre.""" + for graine in range(200): + for source, sortie in self._lot(graine).items(): + self.assertNotEqual(source, sortie, (graine, source)) + + def test_l_unicite_tient_encore_quand_la_bande_a_de_la_place(self): + """Le compromis ne se paie que sur une bande pleine.""" + valeurs = [1000, 2000, 3000, 4000] + sorties = self._lot(7, valeurs=valeurs, bornes=(1, 500000, True)) + self.assertEqual(len(valeurs), len(set(sorties.values()))) + for source, sortie in sorties.items(): + self.assertEqual(4, self._largeur(sortie), (source, sortie)) + + def _saturer(self, valeurs): + """Une table dont TOUTE la bande de ces valeurs est déjà prise. + + C'est la seule façon d'atteindre le recours : sans saturation, le + tirage trouve une place libre et le test passe aussi bien sur le + code d'avant. + """ + table = noyau.Correspondance() + for valeur in valeurs: + bas, haut = noyau.bornes_du_meme_calibre(valeur) + table.nombres_pris.update(range(bas, haut + 1)) + return table + + def test_le_signe_survit_a_la_bande_VRAIMENT_saturee(self): + table = self._saturer([-5]) + rng = random.Random(3) + for tour in range(50): + sortie = noyau.nouveau_nombre( + -5, rng, bornes=(-500000, -1, True), table=table, calibre=True + ) + self.assertLessEqual(sortie, -1, tour) + self.assertGreaterEqual(sortie, -9, tour) + self.assertNotEqual(-5, sortie, tour) + + def test_la_bande_saturee_ne_rend_JAMAIS_l_identite(self): + """La rendre laisserait l'original dans la copie en le comptant + pour remplacé — une fuite annoncée propre.""" + for valeur in (1, 5, 9, -9, 42, -1234, 987654): + with self.subTest(valeur=valeur): + table = self._saturer([valeur]) + rng = random.Random(valeur) + for _ in range(30): + sortie = noyau.nouveau_nombre( + valeur, + rng, + bornes=(-(10**7), 10**7, True), + table=table, + calibre=True, + ) + self.assertNotEqual(valeur, sortie) + self.assertEqual( + self._largeur(valeur), self._largeur(sortie) + ) + + def test_la_sonde_a_trois_candidats_tranche_quand_le_tirage_boucle(self): + """Un tirage peut retomber dix fois sur l'origine : les bornes + tranchent alors à coup sûr, sans parcourir la bande.""" + rng = random.Random(0) + rng.randint = lambda bas, haut: 5 # tout tirage rend l'origine + sortie = noyau._doublon_du_meme_calibre(5, rng, 1, 9, True, 2) + self.assertNotEqual(5, sortie) + self.assertIn(sortie, (1, 9, 2)) + + def test_sous_l_unite_l_elargissement_reste_PERMIS(self): + """Aucune bande ne s'applique sous l'unité : la règle générale + garde son dernier recours, et il SORT de l'étendue. + + L'étendue 0,10..0,12 à deux décimales ne compte que trois places : + cinq valeurs distinctes la saturent. La résolution se lit sur les + valeurs, si bien qu'un 0,153 en porterait trois et ouvrirait + cinquante-et-une places — la plage ne saturerait plus, et le test + passerait sans rien éprouver. + """ + self.assertIsNone(noyau.bornes_du_meme_calibre(0.15)) + table = noyau.Correspondance() + rng = random.Random(5) + sorties = [ + noyau.nouveau_nombre( + valeur, + rng, + bornes=(0.10, 0.12), + table=table, + calibre=True, + ) + for valeur in (0.10, 0.11, 0.12, 0.13, 0.14) + ] + self.assertTrue( + any(s < 0.10 or s > 0.12 for s in sorties), + "aucune sortie n'a quitté l'étendue : rien n'a été élargi", + ) + + def test_sans_calibre_l_elargissement_SORT_de_la_plage(self): + """L'option seule interdit d'élargir ; la règle générale, non. + + Il faut PLUS de valeurs distinctes que de places : redemander une + valeur déjà vue rend la sortie mémorisée, et neuf valeurs pour + neuf places se rangent en permutation sans jamais saturer. + """ + rng = random.Random(11) + table = noyau.Correspondance() + sorties = [ + noyau.nouveau_nombre( + v, rng, bornes=(1, 9, True), table=table, calibre=False + ) + for v in range(1, 15) + ] + self.assertTrue( + any(s > 9 or s < 1 for s in sorties), + "la plage 1..9 est saturée : le recours général doit en sortir", + ) + + def test_une_bande_compte_toujours_au_moins_neuf_places(self): + """L'invariant sur lequel repose la sonde à trois candidats. + + La borne monte à vingt chiffres : c'est à seize que `float()` + cessait d'être exact, et s'arrêter à quinze laissait justement le + défaut hors du test. + """ + for largeur in range(1, 21): + valeur = 10 ** (largeur - 1) + bas, haut = noyau.bornes_du_meme_calibre(valeur) + self.assertGreaterEqual(haut - bas + 1, 9, largeur) + self.assertEqual(largeur, len(str(haut)), largeur) + self.assertEqual(largeur, len(str(bas)), largeur) + + def test_la_borne_haute_de_la_bande_reste_EXACTE(self): + """`float(10**16 - 1)` remonte à 10**16, et `int()` le garde : la + bande gagnait un chiffre. + + Le tirage est FORCÉ à sa borne haute. Un tirage ordinaire ne + rencontrerait l'écart qu'une fois sur neuf mille milliards, si + bien qu'un test statistique passerait des deux côtés sans rien + éprouver — et les bornes vues sont assertées, non la seule sortie. + """ + for largeur in range(14, 21): + with self.subTest(largeur=largeur): + valeur = 10 ** (largeur - 1) + 7 + vues = [] + rng = random.Random(0) + rng.randint = ( + lambda bas, haut: vues.append((bas, haut)) or haut + ) + sortie = noyau.nouveau_nombre( + valeur, + rng, + bornes=(1, 10**22, True), + table=noyau.Correspondance(), + calibre=True, + ) + self.assertEqual( + [(10 ** (largeur - 1), 10**largeur - 1)], vues + ) + self.assertEqual(largeur, self._largeur(sortie), sortie) + + +class TestUnEntierPlusGrandQuUnFlottant(unittest.TestCase): + """Un `.json` porte des entiers exacts, sans plafond de tableur. + + `json.loads` rend un entier de plusieurs centaines de chiffres + intact, et `float()` le refuse au-delà de 1,8e308. Le tirage levait + alors `OverflowError`, que le rattrapage général annonce comme un + format inconnu : le fichier n'était pas anonymisé, et la valeur + repartait en clair. + + Excel s'arrête à 9,99e307, donc aucun classeur n'y mène — mais + `json.loads` n'a pas de plafond, et c'est un format que l'entrée + accepte. + """ + + ENORME = int("9" * 400) + + def _nombre(self, valeur, bornes, **kw): + return noyau.nouveau_nombre( + valeur, + random.Random(0), + bornes=bornes, + table=noyau.Correspondance(), + **kw, + ) + + def test_un_json_porte_bien_un_entier_exact_de_400_chiffres(self): + """La prémisse : sans elle le test ne prouve rien.""" + lu = json.loads('{"x": %s}' % ("9" * 400))["x"] + self.assertIsInstance(lu, int) + self.assertEqual(400, len(str(lu))) + with self.assertRaises(OverflowError): + float(lu) + + def test_une_etendue_trop_grande_ne_leve_plus(self): + sortie = self._nombre(12345, (1, self.ENORME, True)) + self.assertNotEqual(12345, sortie) + + def test_la_valeur_elle_meme_est_REMPLACEE(self): + """Lever laissait la valeur en clair dans la copie.""" + sortie = self._nombre(self.ENORME, (1, self.ENORME, True)) + self.assertNotEqual(self.ENORME, sortie) + + def test_le_calibre_garde_les_quatre_cents_chiffres(self): + """Sa bande est calculée en entiers exacts : elle ne passe pas + par le ramenage, et la largeur survit.""" + sortie = self._nombre( + self.ENORME, (1, self.ENORME, True), calibre=True + ) + self.assertEqual(400, len(str(abs(int(sortie))))) + self.assertNotEqual(self.ENORME, sortie) + + def test_un_decimal_dans_une_telle_colonne_ne_leve_pas(self): + sortie = self._nombre(1.5, (0.5, self.ENORME)) + self.assertNotEqual(1.5, sortie) + + def test_la_borne_ramenee_garde_son_SIGNE(self): + self.assertEqual(sys.float_info.max, noyau._en_flottant(self.ENORME)) + self.assertEqual(-sys.float_info.max, noyau._en_flottant(-self.ENORME)) + self.assertEqual(12.5, noyau._en_flottant(12.5)) + + def test_un_entier_hors_flottant_n_a_pas_de_decimale(self): + self.assertEqual(0, noyau._decimales(self.ENORME)) + self.assertEqual(2, noyau._decimales(0.15)) + + def test_une_bande_entiere_trop_large_se_compte_comme_infinie(self): + """Le parcours des places est alors hors de portée, comme pour + une étendue qui rendait déjà `inf`.""" + sortie = noyau._tirer_libre( + self.ENORME, + random.Random(0), + 10**399, + 10**400 - 1, + True, + set(), + True, + ) + self.assertEqual(400, len(str(sortie))) + self.assertNotEqual(self.ENORME, sortie) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_transform_form.py b/test/test_transform_form.py new file mode 100644 index 0000000..0bc9aae --- /dev/null +++ b/test/test_transform_form.py @@ -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()