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
236 lines
8 KiB
Python
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)
|