erplibre/script/todo/transform_setup.py
Mathieu Benoit 04b9717e96 [ADD] transform data : anonymiser un fichier externe ou une base Odoo
Partager un fichier ou une base demandait d'anonymiser à la main,
colonne par colonne, ou de ne pas partager. L'entrée ouvre un fichier
externe — Excel, Access, CSV, XML, JSON — ou une base Odoo, dit ce
qu'elle porte, puis en tire une copie. L'original n'est jamais touché.

Deux règles gouvernent ce qui sort. Une colonne n'est laissée intacte
que sur son CONTENU mesuré, hors onze étiquettes structurelles : décider
sur l'étiquette recopiait les colonnes les plus identifiantes en les
annonçant protégées. Et la copie est relue octet par octet, un classeur
portant de la donnée en une douzaine d'endroits hors des cellules.

Vérifié : 5 748 tests — 13 rouges, déjà rouges sur master.

--- EN ---

Sharing a file or a database meant anonymising by hand, column by
column, or not sharing. The entry opens an external file — Excel,
Access, CSV, XML, JSON — or an Odoo database, says what it holds, then
draws a copy of it. The original is never touched.

Two rules govern what comes out. A column is left alone on its measured
CONTENT, bar eleven structural labels: deciding on the label copied the
most identifying columns verbatim while announcing them as protected.
And the copy is reread byte by byte, a workbook holding data in a dozen
places that are not cells.

Checked: 5,748 tests — 13 red, already red on master.

Assisted-by: Claude Opus 5
2026-09-11 00:06:39 -04:00

236 lines
8 KiB
Python

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