erplibre/script/data/external_file_formats.py

3653 lines
146 KiB
Python
Raw Normal View History

#!/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 <b>X</b> Nom »
# porte « Nom » en queue de <b>.
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
# <dc:creator> 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_<n>` : 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 — `<tx><v>littéral</v>`
quand il est tapé, `<tx><strRef><f>réf</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<b> » 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: <destination> ».
"""
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]