Three Transform data tests wrote an Excel workbook with openpyxl, which the .venv.erplibre requirements never declared, so the unit suite reported three errors on every install. openpyxl is now declared there. The tests skip when it cannot be imported in their own interpreter, since a venv built before this line still lacks it; the dedicated engine venv is not the right probe, as the tests write their workbook in-process. Checked: 707 Transform tests pass, and with openpyxl made unimportable the three are skipped, not errors. --- FR --- Trois tests de Transform data écrivaient un classeur Excel avec openpyxl, que les dépendances de .venv.erplibre ne déclaraient pas : la suite unitaire rendait trois erreurs sur toute installation. openpyxl y est désormais déclaré. Les tests se sautent quand il ne s'importe pas dans leur interpréteur, un venv bâti avant cette ligne ne l'ayant toujours pas ; le venv dédié du moteur n'est pas la bonne sonde, les tests écrivant leur classeur sur place. Vérifié : 707 tests de Transform passent, et openpyxl rendu inimportable, les trois sont sautés et non en erreur. Assisted-by: Claude Opus 5
237 lines
8 KiB
Python
237 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 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)
|