erplibre/script/todo/transform_setup.py

237 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 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)