erplibre/script/todo/transform_setup.py

238 lines
8 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)
"""L'environnement de lecture des fichiers externes, posé à la demande.
Excel et Access exigent des bibliothèques qu'aucun venv du dépôt ne porte à
coup sûr : `.venv.erplibre` porte l'outillage du CLI — openpyxl compris,
mais seulement depuis qu'il est dans ses dépendances —, le venv Odoo porte
celui d'Odoo, et le lecteur Access n'est dans aucun. 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)