1924 lines
78 KiB
Python
1924 lines
78 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|||
|
|
|
|||
|
|
"""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_<n>` : 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_<n>` 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("<?xml") or tete.startswith("<")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _chaines_distinctes(brut):
|
|||
|
|
"""Quatre ensembles : tout, puis les seuls NŒUDS DE TEXTE.
|
|||
|
|
|
|||
|
|
Le balayage cherchait chaque valeur dans le document entier, où elle
|
|||
|
|
est justement absente : un classeur de cent mille cellules fait dix
|
|||
|
|
mégaoctets, et cent mille recherches infructueuses dessus ne finissent
|
|||
|
|
pas. Or l'anonymisation ramène le contenu à quelques milliers de mots :
|
|||
|
|
la matière DISTINCTE tient en quelques kilooctets.
|
|||
|
|
|
|||
|
|
Dans une partie XML, rien n'est perdu : une valeur de cellule vit
|
|||
|
|
toujours dans un nœud de texte ou une valeur d'attribut, le balisage ne
|
|||
|
|
pouvant pas la traverser. Les parties qui ne sont PAS du XML — un
|
|||
|
|
binaire, un projet VBA, un fichier plat — ne passent pas ici du tout,
|
|||
|
|
voir `_matiere`.
|
|||
|
|
|
|||
|
|
La forme écrite et la forme déséchappée sont rendues SÉPARÉMENT :
|
|||
|
|
mêlées dans un seul ensemble, une même chaîne portant « & » s'y compte
|
|||
|
|
deux fois, et le total des occurrences dépasse alors la tolérance
|
|||
|
|
annoncée — un refus sur du travail légitime.
|
|||
|
|
|
|||
|
|
Les nœuds de texte sont rendus À PART parce qu'une valeur PUREMENT
|
|||
|
|
numérique ne peut pas se chercher dans un attribut : un XML y porte
|
|||
|
|
ses index de ligne, de style et ses compteurs, si bien que tout
|
|||
|
|
nombre à quatre chiffres se retrouve dans `<row r="1203">` d'une
|
|||
|
|
feuille de plus de mille lignes. Une valeur de cellule, elle, vit
|
|||
|
|
dans un nœud de texte — `<v>` —, 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"(?<![\w.])%s(?![\w.])" % re.escape(valeur))
|
|||
|
|
# Une valeur qui porte des lettres se borne aussi, mais par les seuls
|
|||
|
|
# caractères de mot : « 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.
|
|||
|
|
return re.compile(r"(?<!\w)%s(?!\w)" % re.escape(valeur))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _compter(texte, valeur, motif):
|
|||
|
|
"""Les occurrences de `valeur`, bornées quand elle est numérique.
|
|||
|
|
|
|||
|
|
`str.count` reste le chemin rapide et sert de préfiltre : il ne peut
|
|||
|
|
pas manquer une occurrence, seulement en compter de trop. Le motif ne
|
|||
|
|
se paie donc que là où il y a quelque chose à départager.
|
|||
|
|
"""
|
|||
|
|
compte = texte.count(valeur)
|
|||
|
|
if not compte or motif is None:
|
|||
|
|
return compte
|
|||
|
|
return len(motif.findall(texte))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def survivances(chemin, valeurs, tolerees=()):
|
|||
|
|
"""{valeur: [parties du fichier]} pour ce qui subsiste dans la copie.
|
|||
|
|
|
|||
|
|
Un `.xlsx` est un zip : on balaie CHAQUE partie, sans en nommer aucune.
|
|||
|
|
Nommer les parties une à une est ce qui a laissé passer, tour à tour, le
|
|||
|
|
cache d'un graphique, un titre d'axe, un hyperlien de cellule et le nom
|
|||
|
|
d'une colonne de tableau.
|
|||
|
|
|
|||
|
|
On compare des OCCURRENCES et non une appartenance : la portée se décide
|
|||
|
|
par cellule, le balayage ne sait lire que le fichier, et une valeur qui
|
|||
|
|
ne subsiste QUE dans ce que le moteur a annoncé garder n'est pas une
|
|||
|
|
fuite. Le grain est la chaîne distincte — une fuite ajoute toujours une
|
|||
|
|
chaîne que le socle n'a pas.
|
|||
|
|
"""
|
|||
|
|
if not valeurs:
|
|||
|
|
return {}
|
|||
|
|
morceaux = []
|
|||
|
|
try:
|
|||
|
|
socle = _socle_du_graveur()
|
|||
|
|
with zipfile.ZipFile(chemin) as archive:
|
|||
|
|
for nom in archive.namelist():
|
|||
|
|
brut = archive.read(nom).decode("utf-8", "ignore")
|
|||
|
|
morceaux.append(
|
|||
|
|
(
|
|||
|
|
nom,
|
|||
|
|
_matiere(nom, brut),
|
|||
|
|
_matiere(nom, socle.get(nom, "")),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
except (zipfile.BadZipFile, OSError):
|
|||
|
|
try:
|
|||
|
|
with open(chemin, "rb") as fh:
|
|||
|
|
brut = fh.read().decode("utf-8", "ignore")
|
|||
|
|
except OSError:
|
|||
|
|
return {}
|
|||
|
|
nom_plat = os.path.basename(chemin)
|
|||
|
|
morceaux.append(
|
|||
|
|
(
|
|||
|
|
nom_plat,
|
|||
|
|
_matiere(nom_plat, brut, reductible=False),
|
|||
|
|
("", "", "", ""),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
bloc_garde = _joindre(tolerees)
|
|||
|
|
# 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 une
|
|||
|
|
# valeur qui fuit ailleurs. Le saut ne vaut donc que pour l'ÉGALITÉ ;
|
|||
|
|
# le reste retourne au comptage d'occurrences, qui compare ce que la
|
|||
|
|
# copie porte à ce que le moteur a annoncé.
|
|||
|
|
tolerees_exactes = {str(g) for g in tolerees if g}
|
|||
|
|
# Le préfiltre ne se paie que quand il rapporte.
|
|||
|
|
assez = len(valeurs) >= 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())
|